feat: image variants settings screens

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

No files matched your search

+45 -5
View File
@@ -25,6 +25,7 @@ const props = withDefaults(
compactPadding?: boolean
bleedOfflineBanner?: boolean
renderWhileChecking?: boolean
noCurrentNav?: boolean
offlineMessage?: string
offlineIcon?: string
}>(),
@@ -39,6 +40,7 @@ const props = withDefaults(
compactPadding: false,
bleedOfflineBanner: false,
renderWhileChecking: false,
noCurrentNav: false,
},
)
@@ -62,6 +64,26 @@ const session = useSessionStore()
const update = useUpdateStore()
const api = useApi()
const canManageMedia = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const settingsChildren = computed(() => [
{ label: t('Media'), type: 'label' as const },
{
label: t('Variants'),
to: '/settings/media/variants',
icon: 'i-lucide-layers',
active: !props.noCurrentNav && route.path === '/settings/media/variants',
},
{
label: t('Cache'),
icon: 'i-lucide-database',
disabled: true,
active: !props.noCurrentNav && route.path === '/settings/media/cache',
},
])
const navItems = computed(() =>
[
{
@@ -114,10 +136,23 @@ const navItems = computed(() =>
icon: 'i-lucide-settings',
slot: 'settings',
},
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
})),
].map((item) => {
const isSettings = item.to === '/settings'
const parentActive = isSettings
? route.path === item.to
: route.path === item.to || route.path.startsWith(`${item.to}/`)
return {
...item,
active: !props.noCurrentNav && parentActive,
...(isSettings && canManageMedia.value
? {
children: settingsChildren.value,
defaultOpen: route.path.startsWith(item.to),
trailingIcon: 'i-lucide-chevron-right',
}
: {}),
}
}),
)
const routeTitle = computed(() => translate((route.meta.title as string) ?? ''))
@@ -135,8 +170,13 @@ const navUi = computed(() => ({
list: 'flex flex-col gap-0.5',
link: sidebarCollapsed.value
? 'h-8 justify-center gap-0 px-0 text-sm'
: 'h-8 gap-2.5 px-2.5 py-0 text-sm',
: 'h-8 gap-2.5 px-2.5 py-0 text-sm [li[data-state=open]>&]:font-medium [li[data-state=open]>&]:text-highlighted',
linkLeadingIcon: 'size-4',
linkTrailingIcon: 'size-3.5 group-data-[state=open]:rotate-90',
label: 'px-2.5 pt-1.5 pb-0.5 pl-3 text-[11px] leading-4 font-semibold tracking-[.04em] text-muted uppercase',
childList: 'ms-[19px] gap-0.5 border-s border-default ps-0',
childLink: 'h-8 gap-2.5 py-0 pr-2.5 pl-3 text-sm',
childLinkIcon: 'size-3.5',
}))
const loggingOut = ref(false)
const sidebarOpen = ref(false)
@@ -20,6 +20,7 @@ interface ShellOptions {
username?: string
displayName?: string
email?: string
role?: string
width?: number
}
@@ -30,13 +31,14 @@ async function mountShell(path: string, options: ShellOptions = {}) {
username = 'marta',
displayName = '',
email = 'marta@renew.example',
role = undefined,
width = DESKTOP_WIDTH,
} = options
localStorage.clear()
setViewportWidth(width)
setActivePinia(createPinia())
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName }),
getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName, role }),
request: vi.fn().mockResolvedValue(undefined),
})
await router.push(path)
@@ -382,3 +384,41 @@ describe('Account menu: opened from the drawer on mobile', () => {
expect(router.currentRoute.value.path).toBe('/login')
})
})
describe('DashboardShell Settings expands to the Media subpages', () => {
it.each(['owner', 'admin'])(
'shows Media: Variants and Cache under Settings for %s on /settings',
async (role) => {
const wrapper = await mountShell('/settings', { role })
const nav = wrapper.find('nav[aria-label="Main"]')
expect(nav.text()).toContain('Media')
const variants = nav.find('a[href="/settings/media/variants"]')
expect(variants.text()).toContain('Variants')
expect(variants.attributes('aria-current')).toBeUndefined()
const cache = nav
.findAll('[aria-disabled="true"], [disabled]')
.find((el) => el.text().includes('Cache'))
expect(cache).toBeDefined()
expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBe('page')
},
)
it('marks Variants current and Settings not current on the variants page', async () => {
const wrapper = await mountShell('/settings/media/variants', { role: 'admin' })
const nav = wrapper.find('nav[aria-label="Main"]')
expect(nav.find('a[href="/settings/media/variants"]').attributes('aria-current')).toBe(
'page',
)
expect(nav.find('a[href="/settings"]').attributes('aria-current')).toBeUndefined()
})
it('shows no Media subpages without an owner or admin role', async () => {
const wrapper = await mountShell('/settings', { role: 'user' })
const nav = wrapper.find('nav[aria-label="Main"]')
expect(nav.find('a[href="/settings/media/variants"]').exists()).toBe(false)
expect(nav.text()).not.toContain('Cache')
})
})
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const MIN = 0
const MAX = 100
const STEP = 1
const LARGE_STEP = 10
const PATTERN =
'bg-[repeating-linear-gradient(135deg,color-mix(in_srgb,var(--ui-text-muted)_15%,transparent)_0_1px,transparent_1px_12px)]'
const VARIANT_PATTERN =
'bg-[repeating-linear-gradient(45deg,color-mix(in_srgb,var(--ui-text-muted)_25%,transparent)_0_2px,transparent_2px_6px)]'
const CHIP =
'absolute top-2 flex h-5 items-center border border-accented bg-default px-1.5 text-xs font-medium text-highlighted'
const props = defineProps<{
name: string
comparable: boolean
}>()
const position = defineModel<number>({ default: 50 })
const shownName = computed(() => props.name || t('name'))
function clamp(value: number) {
return Math.min(MAX, Math.max(MIN, value))
}
function onInput(event: Event) {
position.value = clamp(Number((event.target as HTMLInputElement).value))
}
function stepFor(event: KeyboardEvent) {
return event.shiftKey ? LARGE_STEP : STEP
}
const KEY_ACTIONS: Record<string, (event: KeyboardEvent) => number> = {
ArrowRight: (event) => position.value + stepFor(event),
ArrowUp: (event) => position.value + stepFor(event),
ArrowLeft: (event) => position.value - stepFor(event),
ArrowDown: (event) => position.value - stepFor(event),
Home: () => MIN,
End: () => MAX,
}
function onKeydown(event: KeyboardEvent) {
const action = KEY_ACTIONS[event.key]
if (!action) {
return
}
event.preventDefault()
position.value = clamp(action(event))
}
</script>
<template>
<div
data-testid="image-compare"
class="relative aspect-3/2 shrink-0 overflow-hidden border border-default"
>
<div
data-testid="compare-original"
class="absolute inset-0 flex items-center justify-center bg-(--ui-color-secondary-200) text-muted dark:bg-(--ui-color-secondary-300)"
:class="PATTERN"
>
<UIcon name="i-lucide-image" class="size-8 opacity-55" />
</div>
<template v-if="comparable">
<div
data-testid="compare-variant"
class="absolute inset-0 bg-accented"
:class="VARIANT_PATTERN"
:style="{ clipPath: `inset(0 0 0 ${position}%)` }"
/>
<span :class="[CHIP, 'left-2']">{{ t('Original') }}</span>
<span :class="[CHIP, 'right-2 font-mono']">{{ shownName }}</span>
<input
type="range"
:min="MIN"
:max="MAX"
:step="STEP"
:value="position"
:aria-label="t('Compare original and {0}', [shownName])"
:aria-valuenow="position"
:aria-valuetext="t('{0}% {1}', [position, shownName])"
class="peer absolute inset-0 z-10 size-full cursor-ew-resize opacity-0"
@input="onInput"
@keydown="onKeydown"
/>
<div
data-testid="compare-divider"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 -ml-px w-0.5 bg-default outline outline-accented"
:style="{ left: `${position}%` }"
/>
<span
data-testid="compare-handle"
aria-hidden="true"
class="pointer-events-none absolute top-1/2 flex size-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center border border-accented bg-default text-highlighted peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-primary max-sm:size-11"
:style="{ left: `${position}%` }"
>
<UIcon name="i-lucide-chevrons-left-right" class="size-4" />
</span>
</template>
<template v-else>
<span :class="[CHIP, 'left-2']">{{ t('Original') }}</span>
<p
class="absolute right-3 bottom-3 left-3 border border-default bg-default px-3 py-2 text-toned"
>
{{ t('Set a width or height to see a preview.') }}
</p>
</template>
</div>
</template>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const SAMPLE_PATH = '/static/uploadedImages/photo.png?v=thumb'
const ERROR_BODY = `{
"status": 400,
"error": "unknown_variant",
"message": "Unknown variant",
"detail": "banner"
}`
const rows = computed(() => [
{ request: SAMPLE_PATH, status: 200, result: t('Image'), mono: true },
{
request: t('Unknown or disabled variant'),
status: 400,
result: t('Unknown variant'),
mono: false,
},
{
request: t('width, height, format or quality'),
status: 400,
result: t('Unsupported parameter'),
mono: false,
},
{ request: t('Missing source image'), status: 404, result: t('Not found'), mono: false },
])
const columns = [
{ id: 'request', header: '' },
{ id: 'status', header: '', meta: { class: { td: 'w-15.5' } } },
{ id: 'result', header: '', meta: { class: { td: 'w-30 sm:w-32.5' } } },
]
function isSuccess(status: number) {
return status === 200
}
</script>
<template>
<UCard
data-testid="request-reference"
:ui="{
root: 'rounded-none border border-default bg-default ring-0',
body: 'flex flex-col gap-3 p-4 sm:p-4',
}"
>
<h2 class="font-semibold text-highlighted">{{ t('How requests are handled') }}</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
<div class="border border-default">
<UTable
:data="rows"
:columns="columns"
:ui="{
base: 'w-full table-fixed',
thead: 'hidden',
separator: 'hidden',
td: 'h-11 whitespace-normal px-0 py-1.5 first:pl-3 sm:h-9',
tbody: 'divide-y-0',
tr: 'border-t border-muted first:border-t-0',
}"
>
<template #request-cell="{ row }">
<span
class="block pr-2.5 text-highlighted"
:class="
row.original.mono ? 'font-mono text-xs break-all' : 'break-words'
"
>{{ row.original.request }}</span
>
</template>
<template #status-cell="{ row }">
<UBadge
color="neutral"
variant="soft"
:icon="
isSuccess(row.original.status)
? 'i-lucide-circle-check'
: 'i-lucide-circle-x'
"
:class="
isSuccess(row.original.status)
? 'bg-(--rc-success-soft) text-success'
: 'bg-(--rc-error-soft) text-error'
"
:ui="{ base: 'h-5 gap-1 px-1.5 py-0 text-xs', leadingIcon: 'size-3' }"
>{{ row.original.status }}</UBadge
>
</template>
<template #result-cell="{ row }">
<span class="text-toned">{{ row.original.result }}</span>
</template>
</UTable>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-xs/5 text-muted">{{ t('Body of a 400 response') }}</span>
<pre
class="m-0 overflow-hidden bg-elevated p-3 font-mono text-xs/4.5 text-highlighted"
>{{ ERROR_BODY }}</pre
>
</div>
</div>
<p class="text-xs/5 text-muted">
{{
t(
'unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode.',
)
}}
</p>
</UCard>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue'
import VariantRowMenu from '@/components/variants/VariantRowMenu.vue'
import VariantStatusSwitch from '@/components/variants/VariantStatusSwitch.vue'
import type { ImageVariant } from '@/composables/useImageVariants'
import { generatedLine, specLine, variantsSummary } from '@/lib/variants/variantFormat'
const props = defineProps<{
items: ImageVariant[]
isEnabled: (item: ImageVariant) => boolean
isLocked: (name: string) => boolean
isPurging: (name: string) => boolean
}>()
const emit = defineEmits<{
toggle: [name: string, value: boolean]
edit: [name: string]
purge: [name: string]
delete: [name: string]
}>()
const summary = computed(() => variantsSummary(props.items))
function nameTone(item: ImageVariant) {
return props.isEnabled(item) ? 'text-highlighted' : 'text-muted'
}
</script>
<template>
<div class="flex flex-col gap-2">
<UCard
v-for="item in items"
:key="item.name"
data-testid="variant-card"
:ui="{
root: 'rounded-none border border-default ring-0',
body: 'flex flex-col gap-1.5 p-3 sm:p-3',
}"
:class="props.isPurging(item.name) ? 'bg-elevated' : 'bg-default'"
>
<div class="flex items-center gap-2">
<span
class="min-w-0 flex-1 truncate font-mono text-base/5 font-medium"
:class="nameTone(item)"
>{{ item.name }}</span
>
<VariantStatusSwitch
:name="item.name"
:enabled="isEnabled(item)"
:purging="isPurging(item.name)"
:locked="isLocked(item.name)"
@toggle="emit('toggle', item.name, $event)"
/>
</div>
<span class="font-mono text-xs/5 text-toned">{{ specLine(item) }}</span>
<div class="flex items-center">
<span class="flex-1 text-muted">{{ generatedLine(item) }}</span>
<VariantRowMenu
:name="item.name"
:disabled="isPurging(item.name)"
@edit="emit('edit', item.name)"
@purge="emit('purge', item.name)"
@delete="emit('delete', item.name)"
/>
</div>
</UCard>
<p data-testid="variants-summary" class="py-1 text-muted">{{ summary }}</p>
</div>
</template>
@@ -0,0 +1,394 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import type { FormMode } from '@/composables/useVariantForm'
import type { VariantFit, VariantFormat } from '@/composables/useImageVariants'
import {
NAME_MAX_LENGTH,
QUALITY_MAX,
QUALITY_MIN,
sliderValue,
usesQuality,
type FieldErrors,
type FieldName,
type VariantFormValues,
} from '@/lib/variants/variantForm'
const FIELD_BASE =
'h-11 px-3 text-lg ring-inset focus:ring-2 focus:ring-primary aria-invalid:focus:ring-error disabled:bg-elevated disabled:text-highlighted disabled:opacity-60 sm:h-10 sm:text-sm'
const FIELD_UI = { help: 'text-xs leading-5', error: 'text-xs leading-5' }
const MONO_FIELD = `${FIELD_BASE} font-mono`
const ERROR_ROW = 'flex items-start gap-1.5'
const values = defineModel<VariantFormValues>('values', { required: true })
const props = defineProps<{
errors: FieldErrors
mode: FormMode
disabled: boolean
dimensionHelpHidden: boolean
generatedFiles: number
saveFailed: boolean
}>()
const emit = defineEmits<{ blur: [field: FieldName]; retry: [] }>()
const isEdit = computed(() => props.mode === 'edit')
const showCounter = computed(() => !isEdit.value && values.value.name !== '')
const fitOptions = computed(() => [
{ value: 'cover', label: t('Cover') },
{ value: 'contain', label: t('Contain') },
{ value: 'inside', label: t('Inside') },
])
const fitHelp = computed(() => {
const helps: Record<VariantFit, string> = {
cover: t('Fills the box and crops what overflows.'),
contain: t('Whole image inside the box, padded to the box size.'),
inside: t('Whole image inside the box, no padding.'),
}
return helps[values.value.fit]
})
const formatOptions = computed(() => [
{ value: 'original', label: t('Original') },
{ value: 'jpeg', label: 'JPEG' },
{ value: 'png', label: 'PNG' },
{ value: 'webp', label: 'WebP' },
])
const showQuality = computed(() => usesQuality(values.value.format))
const qualityModel = computed({
get: () => sliderValue(values.value.quality),
set: (value: number) => {
values.value.quality = String(value)
},
})
const editWarning = computed(() =>
props.generatedFiles === 1
? t(
'Changing dimensions, fit, format or quality purges 1 generated file; it will be regenerated on next request.',
)
: t(
'Changing dimensions, fit, format or quality purges {0} generated files; they will be regenerated on next request.',
[props.generatedFiles],
),
)
function setFit(value: unknown) {
values.value.fit = value as VariantFit
}
function setFormat(value: unknown) {
values.value.format = value as VariantFormat
}
</script>
<template>
<div class="flex min-w-0 flex-col gap-4">
<UAlert
v-if="saveFailed"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t save the variant')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 sm:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<UAlert
v-if="isEdit"
data-testid="edit-warning"
color="warning"
variant="soft"
:close="false"
icon="i-lucide-triangle-alert"
:description="editWarning"
/>
<div class="flex min-w-0 flex-col gap-4">
<UFormField
:ui="FIELD_UI"
data-field="name"
name="name"
:label="t('Name')"
:error="errors.name"
:help="
isEdit
? t('The name can\'t be changed.')
: t('Lowercase letters, numbers and dashes. Can\'t be changed later.')
"
>
<template v-if="showCounter" #hint>
<span data-testid="name-counter" class="font-mono text-xs">{{
`${values.name.length}/${NAME_MAX_LENGTH}`
}}</span>
</template>
<UInput
v-model="values.name"
:placeholder="t('e.g. hero')"
:readonly="isEdit"
:disabled="disabled"
:trailing-icon="isEdit ? 'i-lucide-lock' : undefined"
autocomplete="off"
class="w-full"
:class="{ 'opacity-100': isEdit }"
:ui="{
base: `${MONO_FIELD} ${isEdit ? 'bg-elevated' : ''}`,
trailingIcon: 'size-3.5 text-muted',
}"
@blur="emit('blur', 'name')"
/>
<template v-if="errors.name" #error="{ error }">
<span :class="ERROR_ROW"
><UIcon name="i-lucide-circle-alert" class="mt-0.75 size-3.5 shrink-0" />{{
error
}}</span
>
</template>
</UFormField>
<div class="flex flex-col gap-2">
<div class="grid grid-cols-2 gap-3">
<UFormField
:ui="FIELD_UI"
data-field="width"
name="width"
:label="t('Width')"
:error="errors.width"
>
<UInput
v-model="values.width"
inputmode="numeric"
:placeholder="t('auto')"
:disabled="disabled"
:color="errors.width ? 'error' : undefined"
:highlight="Boolean(errors.width)"
autocomplete="off"
class="w-full"
:ui="{ base: `${FIELD_BASE} pe-10`, trailing: 'pe-3' }"
@blur="emit('blur', 'width')"
>
<template #trailing>
<span class="font-mono text-sm text-muted">px</span>
</template>
</UInput>
<template v-if="errors.width" #error="{ error }">
<span :class="ERROR_ROW"
><UIcon
name="i-lucide-circle-alert"
class="mt-0.75 size-3.5 shrink-0"
/>{{ error }}</span
>
</template>
</UFormField>
<UFormField
:ui="FIELD_UI"
data-field="height"
name="height"
:label="t('Height')"
:error="errors.height"
>
<UInput
v-model="values.height"
inputmode="numeric"
:placeholder="t('auto')"
:disabled="disabled"
:color="errors.height || errors.dimension ? 'error' : undefined"
:highlight="Boolean(errors.height || errors.dimension)"
autocomplete="off"
class="w-full"
:ui="{ base: `${FIELD_BASE} pe-10`, trailing: 'pe-3' }"
@blur="emit('blur', 'height')"
>
<template #trailing>
<span class="font-mono text-sm text-muted">px</span>
</template>
</UInput>
<template v-if="errors.height" #error="{ error }">
<span :class="ERROR_ROW"
><UIcon
name="i-lucide-circle-alert"
class="mt-0.75 size-3.5 shrink-0"
/>{{ error }}</span
>
</template>
</UFormField>
</div>
<p v-if="!dimensionHelpHidden" class="text-xs/5 text-muted">
{{ t('Set a width, a height, or both. 1–8000 px. Images are never enlarged.') }}
</p>
</div>
<UFormField :ui="FIELD_UI" :label="t('Fit')" :help="fitHelp">
<URadioGroup
:model-value="values.fit"
:items="fitOptions"
:disabled="disabled"
variant="card"
orientation="horizontal"
:class="{ 'opacity-60': disabled }"
:ui="{
fieldset: 'grid grid-cols-3 gap-2',
item: 'min-h-11 items-center justify-center rounded-none border-accented p-0 text-highlighted has-data-[state=checked]:border-primary has-data-[state=checked]:bg-elevated sm:min-h-9',
container: 'sr-only',
wrapper: 'ms-0 w-auto',
label: 'flex items-center justify-center gap-1.5 font-normal text-highlighted',
}"
@update:model-value="setFit"
>
<template #label="{ item }">
<span
class="flex items-center gap-1.5"
:class="values.fit === item.value ? 'font-semibold' : ''"
>
<UIcon
v-if="values.fit === item.value"
name="i-lucide-check"
class="size-3.5"
/>
{{ item.label }}
</span>
</template>
</URadioGroup>
</UFormField>
<UFormField
:ui="FIELD_UI"
:label="t('Format')"
:help="
values.format === 'original'
? t('Keeps the format of each source image.')
: undefined
"
>
<USelect
:model-value="values.format"
:items="formatOptions"
:disabled="disabled"
class="w-full"
:ui="{
base: 'h-11 px-3 text-lg ring-inset disabled:bg-elevated disabled:text-highlighted disabled:opacity-60 sm:h-10 sm:text-sm',
}"
@update:model-value="setFormat"
/>
</UFormField>
<UFormField
v-if="showQuality"
:ui="FIELD_UI"
data-field="quality"
name="quality"
:label="t('Quality')"
:error="errors.quality"
:help="t('1–100. Higher is sharper and larger.')"
>
<div class="flex items-center gap-4" :class="{ 'opacity-60': disabled }">
<USlider
v-model="qualityModel"
:min="QUALITY_MIN"
:max="QUALITY_MAX"
:step="1"
:disabled="disabled"
:aria-label="t('Quality')"
class="flex-1"
:ui="{
root: 'h-11 sm:h-6',
track: 'h-1 rounded-none bg-accented',
range: 'rounded-none bg-primary',
thumb: 'size-4 rounded-none border-2 border-default bg-primary ring-0 outline outline-1 outline-primary',
}"
/>
<div class="pt-1.5">
<UInput
v-model="values.quality"
inputmode="numeric"
:aria-label="t('Quality')"
:disabled="disabled"
:color="errors.quality ? 'error' : undefined"
:highlight="Boolean(errors.quality)"
autocomplete="off"
class="w-21"
:ui="{ base: `${MONO_FIELD} disabled:opacity-100` }"
@blur="emit('blur', 'quality')"
/>
</div>
</div>
<template v-if="errors.quality" #error="{ error }">
<span :class="ERROR_ROW"
><UIcon name="i-lucide-circle-alert" class="mt-0.75 size-3.5 shrink-0" />{{
error
}}</span
>
</template>
</UFormField>
<div class="flex flex-col gap-3 pt-1">
<div class="flex min-h-8 items-start gap-3 max-sm:min-h-11">
<div class="flex min-w-0 flex-1 flex-col">
<span id="generate-on-upload-label" class="font-medium text-highlighted">{{
t('Generate on upload')
}}</span>
<span class="text-xs/5 text-muted">{{
t('Creates this variant when an image is uploaded.')
}}</span>
</div>
<div class="pt-0.5">
<USwitch
v-model="values.generateOnUpload"
:disabled="disabled"
aria-labelledby="generate-on-upload-label"
:ui="{
container: 'h-4.5',
base: 'relative h-4.5 w-8 before:absolute before:-inset-3.5 disabled:opacity-40 sm:before:hidden',
thumb: 'size-3.5 data-[state=checked]:translate-x-3.5',
}"
/>
</div>
</div>
<div class="flex min-h-8 items-start gap-3 max-sm:min-h-11">
<div class="flex min-w-0 flex-1 flex-col">
<span id="variant-enabled-label" class="font-medium text-highlighted">{{
t('Enabled')
}}</span>
<span class="text-xs/5 text-muted">{{
t('Disabled variants return 400 on the public site.')
}}</span>
</div>
<div class="pt-0.5">
<USwitch
v-model="values.enabled"
:disabled="disabled"
aria-labelledby="variant-enabled-label"
:ui="{
container: 'h-4.5',
base: 'relative h-4.5 w-8 before:absolute before:-inset-3.5 disabled:opacity-40 sm:before:hidden',
thumb: 'size-3.5 data-[state=checked]:translate-x-3.5',
}"
/>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import VariantFormFields from '@/components/variants/VariantFormFields.vue'
import VariantPreview from '@/components/variants/VariantPreview.vue'
import { ApiError } from '@/composables/useApi'
import type { ImageVariant } from '@/composables/useImageVariants'
import { useVariantForm, type FormMode } from '@/composables/useVariantForm'
import { useVariantToasts } from '@/composables/useVariantToasts'
import { isStatus } from '@/lib/variants/variantErrors'
import {
toDefinition,
toNewVariant,
type FieldName,
type VariantFormValues,
} from '@/lib/variants/variantForm'
import { useImageVariantsStore } from '@/stores/imageVariants'
const DESKTOP_QUERY = '(min-width: 40rem)'
const CONFLICT = 409
const UNPROCESSABLE = 422
const TOAST_STATUSES = [400, 403, 404]
const props = defineProps<{
mode: FormMode
initial: VariantFormValues
variant?: ImageVariant
}>()
const emit = defineEmits<{
close: []
saved: [result: { mode: FormMode; name: string; purgedFiles: number }]
}>()
const store = useImageVariantsStore()
const overlay = useLocalizedOverlay()
const { announceFailure } = useVariantToasts()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const form = useVariantForm(props.mode, props.initial)
const saving = ref(false)
const saveFailed = ref(false)
const discardOpen = ref(false)
const title = computed(() =>
props.mode === 'edit' ? t('Edit variant: {0}', [props.initial.name]) : t('Add variant'),
)
const primaryLabel = computed(() => {
if (saving.value) {
return t('Saving…')
}
return props.mode === 'edit' ? t('Save changes') : t('Add variant')
})
const discardDescription = computed(() =>
form.values.name
? t("You have unsaved changes to “{0}”. If you close now, they're lost.", [
form.values.name,
])
: t("You have unsaved changes. If you close now, they're lost."),
)
function focusField(field: FieldName) {
const target = document.querySelector<HTMLElement>(
`[data-field="${field}"] input:not([type="hidden"])`,
)
target?.focus()
}
function focusOnOpen(event: Event) {
event.preventDefault()
focusField(props.mode === 'edit' ? 'width' : 'name')
}
async function confirmDiscard() {
if (discardOpen.value) {
return
}
discardOpen.value = true
const discard = await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Discard changes?')
},
get description() {
return discardDescription.value
},
get confirmLabel() {
return t('Discard changes')
},
get cancelLabel() {
return t('Keep editing')
},
get pendingLabel() {
return t('Discard changes')
},
tone: 'danger',
action: async () => undefined,
},
})
.open()
discardOpen.value = false
if (discard) {
emit('close')
}
}
function requestClose() {
if (saving.value) {
return
}
if (!form.dirty.value) {
emit('close')
return
}
confirmDiscard()
}
function onOpenChange(isOpen: boolean) {
if (!isOpen) {
requestClose()
}
}
function showInvalidFromServer(body: string | undefined): FieldName | null {
const text = body ?? ''
if (text.includes('lowercase letters')) {
form.showRemote('name', t('Use lowercase letters, numbers and dashes, 2 to 32 characters.'))
return 'name'
}
if (text.includes('Width and height')) {
form.showRemote('width', t('Set a width, a height, or both.'))
return 'width'
}
if (text.includes('Quality')) {
form.showRemote('quality', t('Enter a value from 1 to 100.'))
return 'quality'
}
saveFailed.value = true
return null
}
function handleFailure(error: unknown): FieldName | null {
if (!(error instanceof ApiError)) {
saveFailed.value = true
return null
}
if (isStatus(error, CONFLICT)) {
form.showRemote(
'name',
t('A variant named “{0}” already exists. Choose another name.', [form.values.name]),
)
return 'name'
}
if (isStatus(error, UNPROCESSABLE)) {
return showInvalidFromServer(error.body)
}
if (TOAST_STATUSES.includes(error.status)) {
announceFailure(error)
return null
}
if (error.status !== 401) {
saveFailed.value = true
}
return null
}
async function persist() {
if (props.mode === 'edit' && props.variant) {
const saved = await store.save(props.variant.name, toDefinition(form.values))
emit('saved', { mode: 'edit', name: saved.name, purgedFiles: saved.purged_files ?? 0 })
return
}
const created = await store.create(toNewVariant(form.values))
emit('saved', { mode: 'create', name: created.name, purgedFiles: 0 })
}
async function submit() {
if (saving.value) {
return
}
saveFailed.value = false
const invalid = form.validateAll()
if (invalid) {
focusField(invalid)
return
}
saving.value = true
let toFocus: FieldName | null = null
try {
await persist()
} catch (error) {
toFocus = handleFailure(error)
} finally {
saving.value = false
}
if (toFocus) {
await nextTick()
focusField(toFocus)
}
}
</script>
<template>
<USlideover
:open="true"
side="right"
:title="title"
:description="t('Define a named size and format for generated images.')"
:close="
isDesktop
? {
icon: 'i-lucide-x',
color: 'neutral',
variant: 'ghost',
'aria-label': t('Close'),
class: 'size-7 justify-center text-muted',
}
: false
"
:content="{ onOpenAutoFocus: focusOnOpen }"
:ui="{
content: 'w-full max-w-none sm:max-w-240',
header: 'h-14 min-h-14 gap-3 px-5 py-0 max-sm:gap-1 max-sm:px-1.5',
wrapper: 'min-w-0 flex-1',
title: 'truncate text-lg font-semibold',
description: 'sr-only',
body: 'min-h-0 p-0 sm:overflow-hidden sm:p-0',
footer: 'h-18 gap-2 px-4 py-0 sm:h-16 sm:gap-3 sm:px-5',
}"
@update:open="onOpenChange"
>
<template #actions>
<UButton
v-if="!isDesktop"
icon="i-lucide-arrow-left"
color="neutral"
variant="ghost"
:aria-label="t('Back')"
class="order-first size-11 shrink-0 justify-center"
:ui="{ leadingIcon: 'size-5' }"
@click="requestClose"
/>
</template>
<template #body>
<UForm
:state="form.values"
:validate-on="[]"
class="flex flex-col gap-6 p-4 max-sm:pb-25 sm:grid sm:h-full sm:grid-cols-[440px_minmax(0,1fr)] sm:gap-0 sm:p-0"
@submit="submit"
>
<div class="min-w-0 sm:overflow-y-auto sm:p-5">
<VariantFormFields
v-model:values="form.values"
:errors="form.errors.value"
:mode="mode"
:disabled="saving"
:dimension-help-hidden="form.dimensionHelpHidden.value"
:generated-files="variant?.generated_files ?? 0"
:save-failed="saveFailed"
@blur="form.onBlur"
@retry="submit"
/>
</div>
<div
class="min-w-0 border-t border-default pt-4 sm:overflow-y-auto sm:border-t-0 sm:border-l sm:bg-muted sm:p-5"
>
<VariantPreview :values="form.values" :disabled="saving" />
</div>
</UForm>
</template>
<template #footer>
<div class="flex w-full items-center gap-2 sm:gap-3">
<div v-if="isDesktop" class="flex flex-1 items-center gap-1.5 text-toned">
<template v-if="form.dirty.value">
<UIcon name="i-lucide-circle-dot" class="size-3.5" />
{{ t('Unsaved changes') }}
</template>
</div>
<UButton
color="neutral"
variant="outline"
:disabled="saving"
class="h-11 flex-1 justify-center border border-accented bg-default px-3 text-sm text-highlighted ring-0 sm:h-8 sm:flex-none"
@click="requestClose"
>
{{ t('Cancel') }}
</UButton>
<UButton
:loading="saving"
:disabled="!form.dirty.value || saving"
class="h-11 flex-1 justify-center px-4 text-sm sm:h-8 sm:flex-none"
@click="submit"
>
{{ primaryLabel }}
</UButton>
</div>
</template>
</USlideover>
</template>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import ImageCompare from '@/components/variants/ImageCompare.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { SAMPLE_SOURCE, estimateLabel, estimateVariant } from '@/lib/variants/variantEstimate'
import { parseDimension, sliderValue, type VariantFormValues } from '@/lib/variants/variantForm'
import { formatVariantSize } from '@/lib/variants/variantFormat'
const URL_PREFIX = '/static/uploadedImages/'
const NAME_PLACEHOLDER = '<name>'
const props = defineProps<{
values: VariantFormValues
disabled: boolean
}>()
const position = ref(50)
const { copied, copy } = useCopyToClipboard()
const dimensionsAreValid = computed(
() =>
(props.values.width.trim() === '' || parseDimension(props.values.width) !== null) &&
(props.values.height.trim() === '' || parseDimension(props.values.height) !== null),
)
const estimate = computed(() =>
dimensionsAreValid.value
? estimateVariant({
width: parseDimension(props.values.width),
height: parseDimension(props.values.height),
fit: props.values.fit,
format: props.values.format,
quality: sliderValue(props.values.quality),
})
: null,
)
const shownName = computed(() => props.values.name || t('name'))
const outputLine = computed(() =>
estimate.value ? estimateLabel(estimate.value) : t('No output yet'),
)
const originalLine = computed(
() =>
`${SAMPLE_SOURCE.width} × ${SAMPLE_SOURCE.height} · ${formatVariantSize(SAMPLE_SOURCE.bytes)}`,
)
const hasName = computed(() => props.values.name !== '')
const url = computed(
() => `${URL_PREFIX}${SAMPLE_SOURCE.filename}?v=${props.values.name || NAME_PLACEHOLDER}`,
)
async function onCopy() {
try {
await copy(url.value)
} catch {
return
}
}
</script>
<template>
<div class="flex min-w-0 flex-col gap-3">
<div class="flex items-baseline gap-2">
<h3 class="flex-1 font-semibold text-highlighted">{{ t('Preview') }}</h3>
<span class="text-xs/5 text-muted">{{
t('Sample: {0}', [SAMPLE_SOURCE.filename])
}}</span>
</div>
<ImageCompare v-model="position" :name="values.name" :comparable="estimate !== null" />
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col">
<span class="font-medium text-highlighted">{{ t('Original') }}</span>
<span class="text-xs/5 text-muted">{{ originalLine }}</span>
</div>
<div class="flex flex-col">
<span class="font-mono font-medium text-highlighted">{{ shownName }}</span>
<span class="text-xs/5 text-muted">{{ outputLine }}</span>
</div>
</div>
<p class="text-xs/5 text-muted">
{{
t(
'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.',
)
}}
</p>
<div class="flex flex-col gap-1.5">
<span class="font-medium text-highlighted">{{ t('URL') }}</span>
<div class="flex gap-2">
<div
data-testid="variant-url"
class="flex min-h-11 min-w-0 flex-1 items-center truncate border border-default bg-elevated px-3 font-mono text-xs sm:min-h-8"
:class="hasName ? 'text-highlighted' : 'text-muted'"
>
{{ url }}
</div>
<UButton
color="neutral"
variant="outline"
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
:disabled="!hasName || disabled"
class="h-11 shrink-0 gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 sm:h-8"
@click="onCopy"
>
{{ copied ? t('Copied') : t('Copy') }}
</UButton>
</div>
</div>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{
name: string
disabled: boolean
}>()
const emit = defineEmits<{ edit: []; purge: []; delete: [] }>()
const label = computed(() => t('Actions for {0}', [props.name]))
const items = computed(() => [
[
{
get label() {
return t('Edit')
},
icon: 'i-lucide-pencil',
onSelect: () => emit('edit'),
},
{
get label() {
return t('Purge cache')
},
icon: 'i-lucide-eraser',
onSelect: () => emit('purge'),
},
{
get label() {
return t('Delete')
},
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete'),
},
],
])
</script>
<template>
<UTooltip :text="label">
<UDropdownMenu
:items="items"
:content="{ align: 'end' }"
:ui="{
content: 'w-44',
item: 'h-8 gap-2 px-2 text-sm max-sm:h-11',
itemLeadingIcon: 'size-3.5',
}"
>
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="label"
:disabled="disabled"
class="size-7 shrink-0 justify-center text-highlighted data-[state=open]:outline-2 data-[state=open]:outline-offset-2 data-[state=open]:outline-primary max-sm:size-11"
/>
</UDropdownMenu>
</UTooltip>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
const props = defineProps<{
name: string
enabled: boolean
purging: boolean
locked: boolean
}>()
const emit = defineEmits<{ toggle: [value: boolean] }>()
const word = computed(() => {
if (props.purging) {
return t('Purging…')
}
return props.enabled ? t('Enabled') : t('Disabled')
})
const icon = computed(() => {
if (props.purging) {
return 'i-lucide-loader-circle'
}
return props.enabled ? 'i-lucide-circle-check' : 'i-lucide-circle-off'
})
const tone = computed(() => {
if (props.purging) {
return 'text-info'
}
return props.enabled ? 'text-highlighted' : 'text-muted'
})
</script>
<template>
<div class="flex items-center gap-2 whitespace-nowrap" :class="tone">
<USwitch
:model-value="enabled"
:disabled="locked"
:aria-label="t('Enable variant {0}', [name])"
:ui="{
container: 'h-4.5',
base: 'relative h-4.5 w-8 before:absolute before:-inset-3.5 data-[disabled]:opacity-40 sm:before:hidden',
thumb: 'size-3.5 data-[state=checked]:translate-x-3.5',
}"
@update:model-value="emit('toggle', $event)"
/>
<span class="flex items-center gap-1">
<UIcon :name="icon" class="size-3.5 shrink-0" :class="{ 'animate-spin': purging }" />
{{ word }}
</span>
</div>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { VARIANT_TEMPLATES } from '@/lib/variants/variantForm'
import { fitLabel, formatDimensions, formatLabel } from '@/lib/variants/variantFormat'
const emit = defineEmits<{ add: []; template: [name: string] }>()
function description(template: (typeof VARIANT_TEMPLATES)[number]) {
return [
formatDimensions(template.width, template.height),
fitLabel('cover'),
formatLabel('webp'),
].join(' · ')
}
</script>
<template>
<UEmpty
variant="naked"
icon="i-lucide-layers"
:title="t('No variants yet')"
:description="
t(
'A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these.',
)
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-10',
header: 'max-w-none gap-3',
body: 'w-full max-w-none items-center gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-95 text-pretty text-muted',
actions: 'w-full flex-col items-center justify-center gap-3',
}"
>
<template #actions>
<div class="flex w-full max-w-130 flex-col gap-3 sm:flex-row">
<UButton
v-for="template in VARIANT_TEMPLATES"
:key="template.name"
color="neutral"
variant="outline"
class="min-h-16 flex-1 flex-col items-start justify-start gap-0.5 border border-accented bg-default p-3 text-left ring-0 sm:min-h-0"
@click="emit('template', template.name)"
>
<span class="font-mono font-medium text-highlighted">{{ template.name }}</span>
<span class="text-xs/5 text-muted">{{ description(template) }}</span>
<span class="mt-2 flex items-center gap-1 font-medium text-highlighted">
<UIcon name="i-lucide-plus" class="size-3.5" />
{{ t('Use template') }}
</span>
</UButton>
</div>
<UButton icon="i-lucide-plus" class="h-8 px-4" @click="emit('add')">
{{ t('Add variant') }}
</UButton>
</template>
</UEmpty>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { t } from '@/i18n'
const emit = defineEmits<{ home: [] }>()
</script>
<template>
<UEmpty
variant="naked"
icon="i-lucide-lock"
:title="t('You don\'t have access')"
:description="
t('Only admins can manage image variants. Ask an admin if you need one changed.')
"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-18',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-95 text-pretty text-muted',
}"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-arrow-left"
class="h-11 gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 sm:h-8"
@click="emit('home')"
>
{{ t('Back to Home') }}
</UButton>
</template>
</UEmpty>
</template>
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { t } from '@/i18n'
defineProps<{ desktop: boolean }>()
const DESKTOP_ROWS = 5
const MOBILE_CARDS = 4
const ROW_BARS = ['w-30', 'w-22.5', 'w-15', 'w-15', 'w-20', 'w-40']
const BAR = 'rounded-none bg-accented'
</script>
<template>
<section :aria-label="t('Loading variants')" aria-busy="true">
<div v-if="desktop" class="border border-default bg-default">
<div class="h-9 bg-elevated" />
<div
v-for="row in DESKTOP_ROWS"
:key="row"
class="flex h-12 items-center gap-8 border-t border-muted px-4"
>
<USkeleton
v-for="(width, index) in ROW_BARS"
:key="index"
class="h-3.25"
:class="[BAR, width]"
/>
</div>
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="card in MOBILE_CARDS"
:key="card"
class="flex flex-col gap-2.5 border border-default bg-default p-3"
>
<USkeleton class="h-3.25 w-[40%]" :class="BAR" />
<USkeleton class="h-2.75 w-[70%]" :class="BAR" />
<USkeleton class="h-2.75 w-[55%]" :class="BAR" />
</div>
</div>
</section>
</template>
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { formatNumber, t } from '@/i18n'
import { computed } from 'vue'
import VariantRowMenu from '@/components/variants/VariantRowMenu.vue'
import VariantStatusSwitch from '@/components/variants/VariantStatusSwitch.vue'
import type { ImageVariant } from '@/composables/useImageVariants'
import {
fitLabel,
formatDimensions,
formatLabel,
formatVariantSize,
qualityCell,
variantsSummary,
} from '@/lib/variants/variantFormat'
const props = defineProps<{
items: ImageVariant[]
isEnabled: (item: ImageVariant) => boolean
isLocked: (name: string) => boolean
isPurging: (name: string) => boolean
}>()
const emit = defineEmits<{
toggle: [name: string, value: boolean]
edit: [name: string]
purge: [name: string]
delete: [name: string]
}>()
function fixed(width: string) {
return { meta: { class: { th: width, td: width } } }
}
const columns = computed(() => [
{
id: 'name',
get header() {
return t('Name')
},
},
{
id: 'dimensions',
get header() {
return t('Dimensions')
},
...fixed('w-26'),
},
{
id: 'fit',
get header() {
return t('Fit')
},
...fixed('w-19'),
},
{
id: 'format',
get header() {
return t('Format')
},
...fixed('w-18'),
},
{
id: 'quality',
get header() {
return t('Quality')
},
...fixed('w-17'),
},
{
id: 'generated',
get header() {
return t('Generated')
},
...fixed('w-23'),
},
{
id: 'cache_size',
get header() {
return t('Cache size')
},
...fixed('w-23'),
},
{
id: 'status',
get header() {
return t('Status')
},
...fixed('w-33'),
},
{ id: 'actions', header: '', ...fixed('w-11') },
])
const meta = {
class: {
tr: (row: { original: ImageVariant }) => {
const item = row.original
const tone = props.isEnabled(item) ? 'text-default' : 'text-muted'
return props.isPurging(item.name) ? `bg-elevated ${tone}` : `bg-default ${tone}`
},
},
}
const summary = computed(() => variantsSummary(props.items))
function variant(row: { original: ImageVariant }) {
return row.original
}
function nameTone(row: { original: ImageVariant }) {
return props.isEnabled(variant(row)) ? 'text-highlighted' : 'text-muted'
}
</script>
<template>
<div class="border border-default bg-default">
<UTable
:data="items"
:columns="columns"
:meta="meta"
:ui="{
base: 'w-full table-fixed',
thead: 'bg-elevated [&>tr]:border-t-0',
separator: 'hidden',
th: 'h-9 px-0 py-0 first:pl-4 last:pr-2',
td: 'h-12 px-0 py-0 first:pl-4 last:pr-2',
tbody: 'divide-y-0',
tr: 'border-t border-muted',
}"
>
<template #name-cell="{ row }">
<span class="block truncate font-mono text-sm font-medium" :class="nameTone(row)">{{
variant(row).name
}}</span>
</template>
<template #dimensions-cell="{ row }">
<span class="font-mono text-xs">{{
formatDimensions(variant(row).width, variant(row).height)
}}</span>
</template>
<template #fit-cell="{ row }">{{ fitLabel(variant(row).fit) }}</template>
<template #format-cell="{ row }">{{ formatLabel(variant(row).format) }}</template>
<template #quality-cell="{ row }">{{ qualityCell(variant(row)) }}</template>
<template #generated-cell="{ row }">{{
formatNumber(variant(row).generated_files)
}}</template>
<template #cache_size-cell="{ row }">{{
formatVariantSize(variant(row).cache_size)
}}</template>
<template #status-cell="{ row }">
<VariantStatusSwitch
:name="variant(row).name"
:enabled="isEnabled(variant(row))"
:purging="isPurging(variant(row).name)"
:locked="isLocked(variant(row).name)"
@toggle="emit('toggle', variant(row).name, $event)"
/>
</template>
<template #actions-cell="{ row }">
<span class="flex justify-end">
<VariantRowMenu
:name="variant(row).name"
:disabled="isPurging(variant(row).name)"
@edit="emit('edit', variant(row).name)"
@purge="emit('purge', variant(row).name)"
@delete="emit('delete', variant(row).name)"
/>
</span>
</template>
</UTable>
<div data-testid="variants-summary" class="border-t border-default px-4 py-2.5 text-toned">
{{ summary }}
</div>
</div>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { t } from '@/i18n'
const TAB = 'h-9 gap-1.5 rounded-none border-b-2 px-0 py-0 text-sm hover:bg-transparent max-sm:h-11'
const ACTIVE = 'border-primary font-medium text-highlighted'
const INACTIVE = 'border-transparent font-normal text-toned aria-disabled:opacity-100'
</script>
<template>
<div
role="tablist"
:aria-label="t('Image variants')"
class="flex gap-5 border-b border-default"
>
<UButton
role="tab"
aria-selected="true"
icon="i-lucide-layers"
color="neutral"
variant="ghost"
:class="[TAB, ACTIVE, '-mb-px']"
:ui="{ leadingIcon: 'size-3.5' }"
>
{{ t('Variants') }}
</UButton>
<UButton
role="tab"
aria-selected="false"
aria-disabled="true"
icon="i-lucide-database"
color="neutral"
variant="ghost"
:class="[TAB, INACTIVE, '-mb-px']"
:ui="{ leadingIcon: 'size-3.5' }"
>
{{ t('Cache') }}
</UButton>
</div>
</template>
+99
View File
@@ -0,0 +1,99 @@
import { useApi } from '@/composables/useApi'
export type VariantFit = 'cover' | 'contain' | 'inside'
export type VariantFormat = 'original' | 'jpeg' | 'png' | 'webp'
export interface ImageVariant {
name: string
width: number | null
height: number | null
fit: VariantFit
format: VariantFormat
quality: number | null
generate_on_upload: boolean
enabled: boolean
generated_files: number
cache_size: number
created_at?: string
updated_at?: string
}
export interface VariantDefinition {
width: number | null
height: number | null
fit: VariantFit
format: VariantFormat
quality?: number
generate_on_upload: boolean
enabled: boolean
}
export interface NewVariantRequest extends VariantDefinition {
name: string
}
export interface SavedVariant extends ImageVariant {
purged_files?: number
}
export interface PurgeResult {
purged_files: number
purged_bytes: number
}
const JSON_HEADERS = { 'Content-Type': 'application/json' }
const BASE_PATH = '/v1/image-variants'
function variantPath(name: string) {
return `${BASE_PATH}/${encodeURIComponent(name)}`
}
export function useImageVariants() {
const { request } = useApi()
async function listVariants(): Promise<ImageVariant[]> {
const body = await request<{ items: ImageVariant[] }>(BASE_PATH)
return body.items
}
function createVariant(input: NewVariantRequest): Promise<ImageVariant> {
return request<ImageVariant>(BASE_PATH, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify(input),
})
}
function replaceVariant(name: string, input: VariantDefinition): Promise<SavedVariant> {
return request<SavedVariant>(variantPath(name), {
method: 'PUT',
headers: JSON_HEADERS,
body: JSON.stringify(input),
})
}
function setVariantEnabled(name: string, enabled: boolean): Promise<ImageVariant> {
return request<ImageVariant>(variantPath(name), {
method: 'PATCH',
headers: JSON_HEADERS,
body: JSON.stringify({ enabled }),
})
}
function deleteVariant(name: string): Promise<void> {
return request<void>(variantPath(name), { method: 'DELETE' })
}
function purgeVariant(name: string): Promise<PurgeResult> {
return request<PurgeResult>(`${variantPath(name)}/purge`, { method: 'POST' })
}
return {
listVariants,
createVariant,
replaceVariant,
setVariantEnabled,
deleteVariant,
purgeVariant,
}
}
+93
View File
@@ -0,0 +1,93 @@
import { t } from '@/i18n'
import { computed, reactive, ref, watch } from 'vue'
import {
FIELD_ORDER,
blankValues,
hasChanged,
lacksDimension,
validateField,
type FieldErrors,
type FieldName,
type VariantFormValues,
} from '@/lib/variants/variantForm'
export type FormMode = 'create' | 'edit'
type RemoteField = 'name' | 'width' | 'quality'
export function useVariantForm(mode: FormMode, initial: VariantFormValues) {
const values = reactive<VariantFormValues>({ ...initial })
const baseline: VariantFormValues = mode === 'edit' ? { ...initial } : blankValues()
const shown = reactive<Record<FieldName, boolean>>({
name: false,
width: false,
height: false,
quality: false,
})
const dimensionShown = ref(false)
const remote = reactive<Partial<Record<RemoteField, string>>>({})
const dirty = computed(() => hasChanged(values, baseline))
const dimensionError = computed(() =>
dimensionShown.value && lacksDimension(values)
? t('Set a width, a height, or both.')
: undefined,
)
const errors = computed<FieldErrors>(() => ({
name: remote.name ?? (shown.name ? validateField('name', values) : undefined),
width:
remote.width ??
(shown.width ? validateField('width', values) : undefined) ??
dimensionError.value,
height: shown.height ? validateField('height', values) : undefined,
quality: remote.quality ?? (shown.quality ? validateField('quality', values) : undefined),
dimension: dimensionError.value,
}))
const dimensionHelpHidden = computed(
() =>
Boolean(errors.value.width) ||
Boolean(errors.value.height) ||
Boolean(dimensionError.value),
)
function onBlur(field: FieldName) {
if (values[field] !== baseline[field]) {
shown[field] = true
}
}
function validateAll(): FieldName | null {
for (const field of FIELD_ORDER) {
shown[field] = true
}
dimensionShown.value = true
const invalid = FIELD_ORDER.find((field) => validateField(field, values) !== undefined)
if (invalid) {
return invalid
}
return lacksDimension(values) ? 'width' : null
}
function showRemote(field: RemoteField, message: string) {
remote[field] = message
}
watch(
() => values.name,
() => delete remote.name,
)
watch(
() => [values.width, values.height],
() => delete remote.width,
)
watch(
() => values.quality,
() => delete remote.quality,
)
return { values, dirty, errors, dimensionHelpHidden, onBlur, validateAll, showRemote }
}
+31
View File
@@ -0,0 +1,31 @@
import { useLocalizedToast } from '@/i18n/presentation'
import { toastCopyFor } from '@/lib/variants/variantErrors'
export function useVariantToasts() {
const toast = useLocalizedToast()
function announceFailure(error: unknown) {
toast.add(() => ({
color: 'error' as const,
icon: 'i-lucide-circle-x',
get title() {
return toastCopyFor(error).title
},
get description() {
return toastCopyFor(error).description
},
}))
}
function announceSuccess(message: () => string) {
toast.add(() => ({
color: 'success' as const,
icon: 'i-lucide-circle-check',
get title() {
return message()
},
}))
}
return { announceFailure, announceSuccess }
}
+124
View File
@@ -1237,6 +1237,130 @@ export const messages = {
'Apply {0} GB': 'Appliquer {0} Go',
"Couldn't save the retention period": 'Impossible d’enregistrer la durée de conservation',
'Your previous setting is still in place.': 'Votre réglage précédent est conservé.',
'Image variants': 'Variantes d’image',
Variants: 'Variantes',
Cache: 'Cache',
'Add variant': 'Ajouter une variante',
'Named sizes and formats generated from your original images. Originals are never changed.':
'Tailles et formats nommés générés à partir de vos images originales. Les originaux ne sont jamais modifiés.',
'Loading variants': 'Chargement des variantes',
Generated: 'Générés',
'Cache size': 'Taille du cache',
Fit: 'Ajustement',
Format: 'Format',
Quality: 'Qualité',
Height: 'Hauteur',
Enabled: 'Activée',
Disabled: 'Désactivée',
cover: 'remplir',
contain: 'contenir',
inside: 'à l’intérieur',
Cover: 'Remplir',
Contain: 'Contenir',
Inside: 'À l’intérieur',
auto: 'auto',
name: 'nom',
'q{0}': 'q{0}',
'1 variant': '1 variante',
'{0} variants': '{0} variantes',
'1 generated file': '1 fichier généré',
'{0} generated files': '{0} fichiers générés',
'{0} generated · {1}': '{0} générés · {1}',
'Enable variant {0}': 'Activer la variante {0}',
'Purge cache': 'Purger le cache',
'Use template': 'Utiliser le modèle',
'No variants yet': 'Aucune variante pour l’instant',
'A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these.':
'Une variante est une taille nommée dans laquelle une image peut être demandée, comme ?v=thumb. Commencez par l’une de celles-ci.',
"You don't have access": 'Vous n’avez pas accès',
'Only admins can manage image variants. Ask an admin if you need one changed.':
'Seuls les administrateurs peuvent gérer les variantes d’image. Demandez à un administrateur si vous en avez besoin d’une modifiée.',
'Only admins can manage image variants.':
'Seuls les administrateurs peuvent gérer les variantes d’image.',
'Back to Home': 'Retour à l’accueil',
"Couldn't load variants": 'Impossible de charger les variantes',
"Couldn't save the variant": 'Impossible d’enregistrer la variante',
'Check the request': 'Vérifiez la requête',
"A value in the request isn't valid. Reload the page and try again.":
'Une valeur de la requête n’est pas valide. Rechargez la page et réessayez.',
'This variant or image no longer exists. Refresh the page.':
'Cette variante ou cette image n’existe plus. Actualisez la page.',
"The server couldn't finish. Try again. If it keeps happening, check the server log.":
'Le serveur n’a pas pu terminer. Réessayez. Si le problème persiste, consultez le journal du serveur.',
'Purge cache for “{0}”?': 'Purger le cache de « {0} » ?',
'This removes {0} ({1}). Files are generated again on the next request.':
'Cela supprime {0} ({1}). Les fichiers sont générés à nouveau à la prochaine requête.',
'Purge 1 file': 'Purger 1 fichier',
'Purge {0} files': 'Purger {0} fichiers',
'Purged 1 file from “{0}”': '1 fichier purgé de « {0} »',
'Purged {0} files from “{1}”': '{0} fichiers purgés de « {1} »',
'Delete variant “{0}”?': 'Supprimer la variante « {0} » ?',
"This removes the variant and its {0} ({1}). Articles that use ?v={2} will start returning 400. This can't be undone.":
'Cela supprime la variante et ses fichiers générés ({0}, {1}). Les articles qui utilisent ?v={2} renverront une erreur 400. Cette action est irréversible.',
'Delete variant': 'Supprimer la variante',
'Variant “{0}” deleted': 'Variante « {0} » supprimée',
'Variant “{0}” added': 'Variante « {0} » ajoutée',
'Variant “{0}” saved.': 'Variante « {0} » enregistrée.',
'Variant “{0}” saved. 1 file purged.': 'Variante « {0} » enregistrée. 1 fichier purgé.',
'Variant “{0}” saved. {1} files purged.': 'Variante « {0} » enregistrée. {1} fichiers purgés.',
'Unknown or disabled variant': 'Variante inconnue ou désactivée',
'Unknown variant': 'Variante inconnue',
'width, height, format or quality': 'width, height, format ou quality',
'Unsupported parameter': 'Paramètre non pris en charge',
'Missing source image': 'Image source introuvable',
'How requests are handled': 'Traitement des requêtes',
'Body of a 400 response': 'Corps d’une réponse 400',
'unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode.':
'unsupported_parameter remplace unknown_variant lorsqu’un paramètre brut est envoyé. Il n’existe pas de mode à taille arbitraire.',
'Edit variant: {0}': 'Modifier la variante : {0}',
'Save changes': 'Enregistrer les modifications',
Back: 'Retour',
'Define a named size and format for generated images.':
'Définit une taille et un format nommés pour les images générées.',
"You have unsaved changes to “{0}”. If you close now, they're lost.":
'Vous avez des modifications non enregistrées sur « {0} ». Si vous fermez maintenant, elles seront perdues.',
"You have unsaved changes. If you close now, they're lost.":
'Vous avez des modifications non enregistrées. Si vous fermez maintenant, elles seront perdues.',
'Discard changes?': 'Abandonner les modifications ?',
"The name can't be changed.": 'Le nom ne peut pas être modifié.',
"Lowercase letters, numbers and dashes. Can't be changed later.":
'Lettres minuscules, chiffres et tirets. Ne pourra pas être modifié plus tard.',
'e.g. hero': 'p. ex. hero',
'Set a width, a height, or both. 1–8000 px. Images are never enlarged.':
'Indiquez une largeur, une hauteur, ou les deux. De 1 à 8 000 px. Les images ne sont jamais agrandies.',
'Set a width, a height, or both.': 'Indiquez une largeur, une hauteur, ou les deux.',
'Enter a value from 1 to 8000.': 'Saisissez une valeur de 1 à 8 000.',
'Enter a value from 1 to 100.': 'Saisissez une valeur de 1 à 100.',
'Use lowercase letters, numbers and dashes, 2 to 32 characters.':
'Utilisez des lettres minuscules, des chiffres et des tirets, de 2 à 32 caractères.',
'A variant named “{0}” already exists. Choose another name.':
'Une variante nommée « {0} » existe déjà. Choisissez un autre nom.',
'Fills the box and crops what overflows.': 'Remplit le cadre et rogne ce qui dépasse.',
'Whole image inside the box, padded to the box size.':
'Image entière dans le cadre, complétée jusqu’à la taille du cadre.',
'Whole image inside the box, no padding.': 'Image entière dans le cadre, sans marge.',
'Keeps the format of each source image.': 'Conserve le format de chaque image source.',
'1–100. Higher is sharper and larger.':
'De 1 à 100. Plus la valeur est haute, plus l’image est nette et lourde.',
'Generate on upload': 'Générer à l’import',
'Creates this variant when an image is uploaded.':
'Crée cette variante lorsqu’une image est importée.',
'Disabled variants return 400 on the public site.':
'Les variantes désactivées renvoient 400 sur le site public.',
'Changing dimensions, fit, format or quality purges 1 generated file; it will be regenerated on next request.':
'Modifier les dimensions, l’ajustement, le format ou la qualité purge 1 fichier généré ; il sera régénéré à la prochaine requête.',
'Changing dimensions, fit, format or quality purges {0} generated files; they will be regenerated on next request.':
'Modifier les dimensions, l’ajustement, le format ou la qualité purge {0} fichiers générés ; ils seront régénérés à la prochaine requête.',
'Set a width or height to see a preview.':
'Indiquez une largeur ou une hauteur pour voir un aperçu.',
'Compare original and {0}': 'Comparer l’original et {0}',
'{0}% {1}': '{0} % {1}',
'No output yet': 'Pas encore de résultat',
'Sample: {0}': 'Exemple : {0}',
'{0} · about {1}': '{0} · environ {1}',
'Drag the divider, or focus it and use the arrow keys. The estimate comes from a sample; output is never larger than the source.':
'Faites glisser le séparateur, ou donnez-lui le focus et utilisez les flèches. L’estimation vient d’un échantillon ; le résultat n’est jamais plus lourd que la source.',
URL: 'URL',
} as const
export type MessageKey = keyof typeof messages
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import {
SAMPLE_SOURCE,
estimateLabel,
estimateVariant,
formatPixelSize,
type EstimateRequest,
} from '../variantEstimate'
import { outputSize } from '../variantOutput'
const SAMPLE = { width: 4032, height: 3024 }
function request(overrides: Partial<EstimateRequest>): EstimateRequest {
return {
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
...overrides,
}
}
function labelOf(overrides: Partial<EstimateRequest>) {
return estimateLabel(estimateVariant(request(overrides))!)
}
describe('Output size, never enlarged', () => {
it.each([
[1200, 630, 'cover', '1200×630'],
[800, null, 'cover', '800×600'],
[null, 2400, 'inside', '3200×2400'],
[600, 600, 'contain', '600×600'],
[1000, 1000, 'inside', '1000×750'],
[8000, 8000, 'cover', '4032×3024'],
] as const)('width %s height %s fit %s gives %s', (width, height, fit, expected) => {
const size = outputSize(SAMPLE, { width, height, fit })
expect(formatPixelSize(size!)).toBe(expected)
})
it('has no output without a dimension', () => {
expect(outputSize(SAMPLE, { width: null, height: null, fit: 'cover' })).toBeNull()
})
it('crops to the box ratio when the box is smaller than the source in one dimension', () => {
const size = outputSize(
{ width: 300, height: 200 },
{ width: 400, height: 150, fit: 'cover' },
)
expect(size).toEqual({ width: 300, height: 113 })
})
it('crops a square box out of the sample', () => {
const size = outputSize(SAMPLE, { width: 1000, height: 1000, fit: 'cover' })
expect(size).toEqual({ width: 1000, height: 1000 })
})
})
describe('Size estimate', () => {
it.each([
['hero', 1200, 630, 'cover', 'webp', 82, '1200×630 · about 96 KB'],
['hero', 1200, 630, 'cover', 'webp', 70, '1200×630 · about 71 KB'],
['hero', 1200, 630, 'cover', 'png', 82, '1200×630 · about 310 KB'],
['thumb', 400, 300, 'cover', 'webp', 80, '400×300 · about 18 KB'],
] as const)(
'%s %s×%s %s %s q%s reads %s',
(_name, width, height, fit, format, quality, expected) => {
expect(labelOf({ width, height, fit, format, quality })).toBe(expected)
},
)
it('gives a smaller or equal estimate for a lower quality', () => {
const bytes = (quality: number) => estimateVariant(request({ quality }))!.bytes
const sizes = [1, 20, 40, 60, 80, 100].map(bytes)
expect(sizes).toEqual([...sizes].sort((left, right) => left - right))
})
it('never exceeds the original size', () => {
const estimate = estimateVariant(
request({ width: 8000, height: 8000, format: 'png', quality: 100 }),
)
expect(estimate!.bytes).toBeLessThanOrEqual(SAMPLE_SOURCE.bytes)
})
it('is empty without a dimension', () => {
expect(estimateVariant(request({ width: null, height: null }))).toBeNull()
})
})
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest'
import {
VARIANT_TEMPLATES,
blankValues,
hasChanged,
lacksDimension,
sliderValue,
toDefinition,
toNewVariant,
validateField,
valuesFromTemplate,
valuesFromVariant,
type FieldName,
type VariantFormValues,
} from '../variantForm'
const NAME_ERROR = 'Use lowercase letters, numbers and dashes, 2 to 32 characters.'
const RANGE_ERROR = 'Enter a value from 1 to 8000.'
const QUALITY_ERROR = 'Enter a value from 1 to 100.'
function values(overrides: Partial<VariantFormValues>): VariantFormValues {
return { ...blankValues(), name: 'hero', width: '1200', height: '630', ...overrides }
}
describe('Field errors', () => {
it.each([
['name', { name: 'Hero banner' }, NAME_ERROR],
['name', { name: 'a' }, NAME_ERROR],
['name', { name: 'a'.repeat(33) }, NAME_ERROR],
['width', { width: '9000' }, RANGE_ERROR],
['width', { width: '0' }, RANGE_ERROR],
['height', { height: '8001' }, RANGE_ERROR],
['quality', { quality: '140' }, QUALITY_ERROR],
['quality', { quality: '0' }, QUALITY_ERROR],
] as [FieldName, Partial<VariantFormValues>, string][])(
'%s %j reads the message',
(field, overrides, message) => {
expect(validateField(field, values(overrides))).toBe(message)
},
)
it.each([
['name', { name: 'ab' }],
['name', { name: 'a-b-c' }],
['name', { name: 'a'.repeat(32) }],
['width', { width: '1' }],
['height', { height: '8000' }],
['quality', { quality: '1' }],
['quality', { quality: '100' }],
] as [FieldName, Partial<VariantFormValues>][])('%s %j shows no error', (field, overrides) => {
expect(validateField(field, values(overrides))).toBeUndefined()
})
it('does not validate the quality of a PNG', () => {
expect(validateField('quality', values({ format: 'png', quality: '999' }))).toBeUndefined()
})
it('treats an empty dimension as absent', () => {
expect(validateField('width', values({ width: '' }))).toBeUndefined()
expect(lacksDimension(values({ width: '', height: '' }))).toBe(true)
expect(lacksDimension(values({ width: '', height: '9' }))).toBe(false)
})
})
describe('Request body', () => {
it('builds the create body from the form', () => {
const body = toNewVariant(
values({ format: 'webp', quality: '82', generateOnUpload: true, enabled: true }),
)
expect(body).toEqual({
name: 'hero',
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
generate_on_upload: true,
enabled: true,
})
})
it('sends an empty dimension as null', () => {
const body = toNewVariant(values({ name: 'card', width: '800', height: '' }))
expect(body.width).toBe(800)
expect(body.height).toBeNull()
})
it('omits the quality of a PNG', () => {
const definition = toDefinition(values({ format: 'png', quality: '70' }))
expect('quality' in definition).toBe(false)
})
})
describe('Form values', () => {
it('starts blank with the documented defaults', () => {
expect(blankValues()).toEqual({
name: '',
width: '',
height: '',
fit: 'cover',
format: 'original',
quality: '80',
generateOnUpload: false,
enabled: true,
})
})
it.each([
['thumb', '400', '300', '80'],
['hero', '1200', '630', '82'],
])('template %s pre-fills %s×%s q%s as cover WebP', (name, width, height, quality) => {
const template = VARIANT_TEMPLATES.find((entry) => entry.name === name)!
expect(valuesFromTemplate(template)).toMatchObject({
name,
width,
height,
quality,
fit: 'cover',
format: 'webp',
generateOnUpload: false,
enabled: true,
})
})
it('reads a stored PNG variant with a default hidden quality', () => {
const form = valuesFromVariant({
name: 'print',
width: null,
height: 2400,
fit: 'inside',
format: 'png',
quality: null,
generate_on_upload: false,
enabled: false,
generated_files: 74,
cache_size: 0,
})
expect([form.width, form.height, form.quality]).toEqual(['', '2400', '80'])
})
it('detects a change against the baseline', () => {
expect(hasChanged(blankValues(), blankValues())).toBe(false)
expect(hasChanged({ ...blankValues(), name: 'x' }, blankValues())).toBe(true)
})
it('clamps the slider position', () => {
expect([sliderValue('60'), sliderValue('140'), sliderValue('0'), sliderValue('')]).toEqual([
60, 100, 1, 1,
])
})
})
@@ -0,0 +1,170 @@
import { afterEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import type { ImageVariant } from '@/composables/useImageVariants'
import {
formatDimensions,
formatLabel,
formatVariantSize,
generatedLine,
qualityCell,
specLine,
variantsSummary,
} from '../variantFormat'
const MB = 1048576
function variant(overrides: Partial<ImageVariant>): ImageVariant {
return {
name: 'hero',
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
generate_on_upload: false,
enabled: true,
generated_files: 244,
cache_size: 84 * MB,
...overrides,
}
}
function defaultFixture(): ImageVariant[] {
return [
variant({
name: 'card',
width: 800,
height: null,
fit: 'inside',
quality: 80,
generated_files: 318,
cache_size: 58 * MB,
}),
variant({ name: 'hero' }),
variant({
name: 'og',
format: 'jpeg',
quality: 80,
generated_files: 96,
cache_size: 9 * MB,
}),
variant({
name: 'print',
width: null,
height: 2400,
fit: 'inside',
format: 'png',
quality: null,
enabled: false,
generated_files: 74,
cache_size: 21 * MB,
}),
variant({
name: 'square',
width: 600,
height: 600,
format: 'jpeg',
quality: 85,
generated_files: 140,
cache_size: 19 * MB,
}),
variant({
name: 'thumb',
width: 400,
height: 300,
quality: 80,
generated_files: 412,
cache_size: 21 * MB,
}),
]
}
afterEach(() => {
localStorage.clear()
initializeLocale()
})
describe('Dimensions, quality and format cells', () => {
it.each([
[400, 300, 'webp', 80, '400×300', 'WebP', '80'],
[800, null, 'jpeg', 80, '800 × auto', 'JPEG', '80'],
[null, 2400, 'png', null, 'auto × 2400', 'PNG', '—'],
[600, 600, 'original', 82, '600×600', 'Original', '82'],
] as const)(
'width %s height %s format %s quality %s reads %s, %s, %s',
(width, height, format, quality, dimensions, label, cell) => {
const entry = variant({ width, height, format, quality })
expect(formatDimensions(entry.width, entry.height)).toBe(dimensions)
expect(formatLabel(entry.format)).toBe(label)
expect(qualityCell(entry)).toBe(cell)
},
)
})
describe('Spec line of a mobile card', () => {
it('joins dimensions, fit, format and quality', () => {
expect(specLine(variant({}))).toBe('1200×630 · cover · WebP · q82')
})
it('has no quality part for print', () => {
const print = defaultFixture()[3]!
expect(specLine(print)).toBe('auto × 2400 · inside · PNG')
})
it('reads the generated count and size', () => {
expect(generatedLine(variant({}))).toBe('244 generated · 84 MB')
})
})
describe('Footer sums are computed from the list', () => {
it('sums the Default fixture', () => {
expect(variantsSummary(defaultFixture())).toBe(
'6 variants · 1,284 generated files · 212 MB',
)
})
it('uses the singular for one variant with one file', () => {
const thumb = variant({ name: 'thumb', generated_files: 1, cache_size: 18 * 1024 })
expect(variantsSummary([thumb])).toBe('1 variant · 1 generated file · 18 KB')
})
it('recomputes after a deletion', () => {
const rest = defaultFixture().filter((entry) => entry.name !== 'hero')
expect(variantsSummary(rest)).toBe('5 variants · 1,040 generated files · 128 MB')
})
it('reads an empty cache as 0 KB', () => {
expect(variantsSummary([variant({ generated_files: 0, cache_size: 0 })])).toBe(
'1 variant · 0 generated files · 0 KB',
)
})
})
describe('Variant size formatter', () => {
it.each([
[0, '0 KB'],
[1, '1 KB'],
[18 * 1024, '18 KB'],
[1023 * 1024, '1023 KB'],
[MB, '1 MB'],
[Math.round(3.9 * MB), '3.9 MB'],
[84 * MB, '84 MB'],
[88080384, '84 MB'],
[2048 * MB, '2 GB'],
])('formats %s bytes as %s', (bytes, expected) => {
expect(formatVariantSize(bytes)).toBe(expected)
})
it('uses French units and decimal comma', () => {
setLocale('fr')
expect([formatVariantSize(18 * 1024), formatVariantSize(Math.round(3.9 * MB))]).toEqual([
'18 Ko',
'3,9 Mo',
])
})
})
+47
View File
@@ -0,0 +1,47 @@
import { t } from '@/i18n'
import { ApiError } from '@/composables/useApi'
export interface ToastCopy {
title: string
description: string
}
const BAD_REQUEST = 400
const FORBIDDEN = 403
const NOT_FOUND = 404
export function toastCopyFor(error: unknown): ToastCopy {
const status = error instanceof ApiError ? error.status : 0
if (status === BAD_REQUEST) {
return {
title: t('Check the request'),
description: t("A value in the request isn't valid. Reload the page and try again."),
}
}
if (status === FORBIDDEN) {
return {
title: t("You don't have access"),
description: t('Only admins can manage image variants.'),
}
}
if (status === NOT_FOUND) {
return {
title: t('Not found'),
description: t('This variant or image no longer exists. Refresh the page.'),
}
}
return {
title: t('Something went wrong'),
description: t(
"The server couldn't finish. Try again. If it keeps happening, check the server log.",
),
}
}
export function isStatus(error: unknown, status: number) {
return error instanceof ApiError && error.status === status
}
export function isHandledByApiClient(error: unknown) {
return error instanceof ApiError && error.status === 401
}
+64
View File
@@ -0,0 +1,64 @@
import { t } from '@/i18n'
import type { VariantFormat } from '@/composables/useImageVariants'
import { formatVariantSize } from '@/lib/variants/variantFormat'
import { outputSize, type OutputRequest, type PixelSize } from '@/lib/variants/variantOutput'
export type { PixelSize } from '@/lib/variants/variantOutput'
const BYTES_PER_MB = 1024 * 1024
const FULL_QUALITY = 100
const PNG_BYTES_PER_PIXEL = 0.42
const SIZE_EXPONENT = 0.885
const QUALITY_EXPONENT = 1.9
const LOSSY_BASE: Record<Exclude<VariantFormat, 'png'>, number> = {
webp: 0.9,
jpeg: 1.4,
original: 1.4,
}
export const SAMPLE_SOURCE = {
filename: 'north-wall.jpg',
width: 4032,
height: 3024,
bytes: Math.round(3.9 * BYTES_PER_MB),
}
export interface EstimateRequest extends OutputRequest {
format: VariantFormat
quality: number
}
export interface VariantEstimate {
size: PixelSize
bytes: number
}
function rawBytes(pixels: number, request: EstimateRequest) {
if (request.format === 'png') {
return pixels * PNG_BYTES_PER_PIXEL
}
const quality = Math.min(FULL_QUALITY, Math.max(1, request.quality)) / FULL_QUALITY
return (
LOSSY_BASE[request.format] *
Math.pow(pixels, SIZE_EXPONENT) *
Math.pow(quality, QUALITY_EXPONENT)
)
}
export function estimateVariant(request: EstimateRequest): VariantEstimate | null {
const size = outputSize(SAMPLE_SOURCE, request)
if (size === null) {
return null
}
const bytes = Math.min(SAMPLE_SOURCE.bytes, rawBytes(size.width * size.height, request))
return { size, bytes }
}
export function formatPixelSize(size: PixelSize) {
return `${size.width}×${size.height}`
}
export function estimateLabel(estimate: VariantEstimate) {
return t('{0} · about {1}', [formatPixelSize(estimate.size), formatVariantSize(estimate.bytes)])
}
+154
View File
@@ -0,0 +1,154 @@
import { t } from '@/i18n'
import type {
ImageVariant,
NewVariantRequest,
VariantDefinition,
VariantFit,
VariantFormat,
} from '@/composables/useImageVariants'
export const NAME_MAX_LENGTH = 32
const DIMENSION_MIN = 1
const DIMENSION_MAX = 8000
export const QUALITY_MIN = 1
export const QUALITY_MAX = 100
const DEFAULT_QUALITY = 80
const NAME_PATTERN = /^[a-z0-9-]{2,32}$/
const WHOLE_NUMBER = /^\d+$/
export type FieldName = 'name' | 'width' | 'height' | 'quality'
export interface VariantFormValues {
name: string
width: string
height: string
fit: VariantFit
format: VariantFormat
quality: string
generateOnUpload: boolean
enabled: boolean
}
export type FieldErrors = Partial<Record<FieldName, string>> & { dimension?: string }
export const FIELD_ORDER: FieldName[] = ['name', 'width', 'height', 'quality']
export const VARIANT_TEMPLATES = [
{ name: 'thumb', width: 400, height: 300, quality: 80 },
{ name: 'hero', width: 1200, height: 630, quality: 82 },
] as const
export function blankValues(): VariantFormValues {
return {
name: '',
width: '',
height: '',
fit: 'cover',
format: 'original',
quality: String(DEFAULT_QUALITY),
generateOnUpload: false,
enabled: true,
}
}
export function valuesFromTemplate(
template: (typeof VARIANT_TEMPLATES)[number],
): VariantFormValues {
return {
...blankValues(),
name: template.name,
width: String(template.width),
height: String(template.height),
format: 'webp',
quality: String(template.quality),
}
}
export function valuesFromVariant(variant: ImageVariant): VariantFormValues {
return {
name: variant.name,
width: variant.width === null ? '' : String(variant.width),
height: variant.height === null ? '' : String(variant.height),
fit: variant.fit,
format: variant.format,
quality: String(variant.quality ?? DEFAULT_QUALITY),
generateOnUpload: variant.generate_on_upload,
enabled: variant.enabled,
}
}
export function hasChanged(current: VariantFormValues, initial: VariantFormValues) {
return (Object.keys(initial) as (keyof VariantFormValues)[]).some(
(key) => current[key] !== initial[key],
)
}
export function usesQuality(format: VariantFormat) {
return format !== 'png'
}
function wholeNumberInRange(text: string, min: number, max: number) {
const trimmed = text.trim()
if (!WHOLE_NUMBER.test(trimmed)) {
return null
}
const value = Number(trimmed)
return value >= min && value <= max ? value : null
}
export function parseDimension(text: string) {
return wholeNumberInRange(text, DIMENSION_MIN, DIMENSION_MAX)
}
function parseQuality(text: string) {
return wholeNumberInRange(text, QUALITY_MIN, QUALITY_MAX)
}
export function validateField(field: FieldName, values: VariantFormValues): string | undefined {
if (field === 'name') {
return NAME_PATTERN.test(values.name)
? undefined
: t('Use lowercase letters, numbers and dashes, 2 to 32 characters.')
}
if (field === 'quality') {
return !usesQuality(values.format) || parseQuality(values.quality) !== null
? undefined
: t('Enter a value from 1 to 100.')
}
const text = values[field]
return text.trim() === '' || parseDimension(text) !== null
? undefined
: t('Enter a value from 1 to 8000.')
}
export function lacksDimension(values: VariantFormValues) {
return values.width.trim() === '' && values.height.trim() === ''
}
export function sliderValue(text: string) {
const parsed = Number.parseInt(text, 10)
if (Number.isNaN(parsed)) {
return QUALITY_MIN
}
return Math.min(QUALITY_MAX, Math.max(QUALITY_MIN, parsed))
}
export function toDefinition(values: VariantFormValues): VariantDefinition {
const definition: VariantDefinition = {
width: parseDimension(values.width),
height: parseDimension(values.height),
fit: values.fit,
format: values.format,
generate_on_upload: values.generateOnUpload,
enabled: values.enabled,
}
if (usesQuality(values.format)) {
definition.quality = parseQuality(values.quality) ?? DEFAULT_QUALITY
}
return definition
}
export function toNewVariant(values: VariantFormValues): NewVariantRequest {
return { name: values.name, ...toDefinition(values) }
}
+93
View File
@@ -0,0 +1,93 @@
import { formatNumber, t } from '@/i18n'
import type { ImageVariant, VariantFit, VariantFormat } from '@/composables/useImageVariants'
const BYTES_PER_KB = 1024
const BYTES_PER_MB = BYTES_PER_KB * 1024
const KB_PER_MB = 1024
const MB_PER_GB = 1024
const NO_QUALITY = '—'
const SEPARATOR = ' · '
function oneDecimal(value: number) {
return Math.round(value * 10) / 10
}
export function formatVariantSize(bytes: number) {
const kilobytes = Math.round(bytes / BYTES_PER_KB)
if (bytes === 0) {
return t('{0} KB', [0])
}
if (kilobytes < KB_PER_MB) {
return t('{0} KB', [Math.max(1, kilobytes)])
}
const megabytes = bytes / BYTES_PER_MB
if (megabytes < MB_PER_GB) {
return t('{0} MB', [oneDecimal(megabytes)])
}
return t('{0} GB', [oneDecimal(megabytes / MB_PER_GB)])
}
export function formatDimensions(width: number | null, height: number | null) {
if (width !== null && height !== null) {
return `${width}×${height}`
}
if (width !== null) {
return `${width} × ${t('auto')}`
}
return `${t('auto')} × ${height ?? ''}`
}
export function formatLabel(format: VariantFormat) {
const labels: Record<VariantFormat, string> = {
webp: 'WebP',
jpeg: 'JPEG',
png: 'PNG',
original: t('Original'),
}
return labels[format]
}
export function fitLabel(fit: VariantFit) {
const labels: Record<VariantFit, string> = {
cover: t('cover'),
contain: t('contain'),
inside: t('inside'),
}
return labels[fit]
}
function hasQuality(variant: Pick<ImageVariant, 'format' | 'quality'>) {
return variant.format !== 'png' && variant.quality !== null
}
export function qualityCell(variant: Pick<ImageVariant, 'format' | 'quality'>) {
return hasQuality(variant) ? String(variant.quality) : NO_QUALITY
}
export function specLine(variant: ImageVariant) {
const parts = [
formatDimensions(variant.width, variant.height),
fitLabel(variant.fit),
formatLabel(variant.format),
]
if (hasQuality(variant)) {
parts.push(t('q{0}', [variant.quality ?? 0]))
}
return parts.join(SEPARATOR)
}
export function generatedLine(variant: ImageVariant) {
return t('{0} generated · {1}', [
formatNumber(variant.generated_files),
formatVariantSize(variant.cache_size),
])
}
export function variantsSummary(items: readonly ImageVariant[]) {
const files = items.reduce((total, item) => total + item.generated_files, 0)
const bytes = items.reduce((total, item) => total + item.cache_size, 0)
const variants = items.length === 1 ? t('1 variant') : t('{0} variants', [items.length])
const generated =
files === 1 ? t('1 generated file') : t('{0} generated files', [formatNumber(files)])
return [variants, generated, formatVariantSize(bytes)].join(SEPARATOR)
}
+54
View File
@@ -0,0 +1,54 @@
import type { VariantFit } from '@/composables/useImageVariants'
export interface PixelSize {
width: number
height: number
}
export interface OutputRequest {
width: number | null
height: number | null
fit: VariantFit
}
function scaled(size: PixelSize, factor: number): PixelSize {
return { width: Math.round(size.width * factor), height: Math.round(size.height * factor) }
}
function singleDimension(source: PixelSize, width: number | null, height: number | null) {
const factor = width !== null ? width / source.width : (height ?? 0) / source.height
return scaled(source, Math.min(1, factor))
}
function cropToBox(source: PixelSize, box: PixelSize): PixelSize {
const boxRatio = box.width / box.height
if (source.width / source.height > boxRatio) {
return { width: source.height * boxRatio, height: source.height }
}
return { width: source.width, height: source.width / boxRatio }
}
function coverBox(source: PixelSize, box: PixelSize) {
if (box.width >= source.width && box.height >= source.height) {
return source
}
const crop = cropToBox(source, box)
return scaled(crop, Math.min(1, box.width / crop.width))
}
export function outputSize(source: PixelSize, request: OutputRequest): PixelSize | null {
const { width, height, fit } = request
if (width === null && height === null) {
return null
}
if (width === null || height === null) {
return singleDimension(source, width, height)
}
if (fit === 'contain') {
return { width, height }
}
if (fit === 'inside') {
return scaled(source, Math.min(1, width / source.width, height / source.height))
}
return coverBox(source, { width, height })
}
+5
View File
@@ -106,6 +106,11 @@ const router = createRouter({
component: () => import('@/views/SettingsView.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/settings/media/variants',
component: () => import('@/views/VariantsPage.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/NotFoundView.vue'),
+144
View File
@@ -0,0 +1,144 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ApiError } from '@/composables/useApi'
import {
useImageVariants,
type ImageVariant,
type NewVariantRequest,
type PurgeResult,
type SavedVariant,
type VariantDefinition,
} from '@/composables/useImageVariants'
export type VariantsLoadState = 'loading' | 'ready' | 'error' | 'forbidden'
const FORBIDDEN_STATUS = 403
const UNAUTHORIZED_STATUS = 401
function byName(left: ImageVariant, right: ImageVariant) {
if (left.name === right.name) {
return 0
}
return left.name < right.name ? -1 : 1
}
export const useImageVariantsStore = defineStore('imageVariants', () => {
const api = useImageVariants()
const items = ref<ImageVariant[]>([])
const loadState = ref<VariantsLoadState>('loading')
const toggling = ref<Record<string, boolean>>({})
const purging = ref<string[]>([])
const isEmpty = computed(() => loadState.value === 'ready' && items.value.length === 0)
function find(name: string) {
return items.value.find((item) => item.name === name)
}
function enabledOf(item: ImageVariant) {
return toggling.value[item.name] ?? item.enabled
}
function isToggling(name: string) {
return name in toggling.value
}
function isPurging(name: string) {
return purging.value.includes(name)
}
function isBusy(name: string) {
return isToggling(name) || isPurging(name)
}
function replace(variant: ImageVariant) {
items.value = items.value.map((item) => (item.name === variant.name ? variant : item))
}
async function load() {
loadState.value = 'loading'
try {
items.value = await api.listVariants()
loadState.value = 'ready'
} catch (error) {
if (error instanceof ApiError && error.status === FORBIDDEN_STATUS) {
loadState.value = 'forbidden'
return
}
if (error instanceof ApiError && error.status === UNAUTHORIZED_STATUS) {
return
}
loadState.value = 'error'
}
}
async function reloadQuietly() {
try {
items.value = await api.listVariants()
} catch {
return
}
}
async function toggle(name: string, enabled: boolean) {
toggling.value = { ...toggling.value, [name]: enabled }
try {
replace(await api.setVariantEnabled(name, enabled))
} finally {
const next = { ...toggling.value }
delete next[name]
toggling.value = next
}
}
async function purge(name: string): Promise<PurgeResult> {
purging.value = [...purging.value, name]
try {
const result = await api.purgeVariant(name)
const current = find(name)
if (current) {
replace({ ...current, generated_files: 0, cache_size: 0 })
}
return result
} finally {
purging.value = purging.value.filter((entry) => entry !== name)
}
}
async function remove(name: string) {
await api.deleteVariant(name)
items.value = items.value.filter((item) => item.name !== name)
}
async function create(input: NewVariantRequest) {
const created = await api.createVariant(input)
items.value = [...items.value, created].sort(byName)
return created
}
async function save(name: string, definition: VariantDefinition): Promise<SavedVariant> {
const saved = await api.replaceVariant(name, definition)
const variant: SavedVariant = { ...saved }
delete variant.purged_files
replace(variant)
return saved
}
return {
items,
loadState,
isEmpty,
find,
enabledOf,
isToggling,
isPurging,
isBusy,
load,
reloadQuietly,
toggle,
purge,
remove,
create,
save,
}
})
+420
View File
@@ -0,0 +1,420 @@
<script setup lang="ts">
import { formatNumber, t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useRouter } from 'vue-router'
import { useLocalizedOverlay } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import RequestReferenceCard from '@/components/variants/RequestReferenceCard.vue'
import VariantCardList from '@/components/variants/VariantCardList.vue'
import VariantFormSlideover from '@/components/variants/VariantFormSlideover.vue'
import VariantsEmpty from '@/components/variants/VariantsEmpty.vue'
import VariantsForbidden from '@/components/variants/VariantsForbidden.vue'
import VariantsSkeleton from '@/components/variants/VariantsSkeleton.vue'
import VariantsTable from '@/components/variants/VariantsTable.vue'
import VariantsTabs from '@/components/variants/VariantsTabs.vue'
import type { ImageVariant } from '@/composables/useImageVariants'
import type { FormMode } from '@/composables/useVariantForm'
import { useVariantToasts } from '@/composables/useVariantToasts'
import { isHandledByApiClient, isStatus } from '@/lib/variants/variantErrors'
import {
VARIANT_TEMPLATES,
blankValues,
valuesFromTemplate,
valuesFromVariant,
type VariantFormValues,
} from '@/lib/variants/variantForm'
import { formatVariantSize } from '@/lib/variants/variantFormat'
import { useImageVariantsStore } from '@/stores/imageVariants'
import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 40rem)'
const SKELETON_DELAY_MS = 150
const NOT_FOUND = 404
interface FormTarget {
mode: FormMode
initial: VariantFormValues
variant?: ImageVariant
}
const router = useRouter()
const session = useSessionStore()
const store = useImageVariantsStore()
const overlay = useLocalizedOverlay()
const { announceFailure, announceSuccess } = useVariantToasts()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const showSkeleton = ref(false)
const formTarget = ref<FormTarget | null>(null)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let formOpener: HTMLElement | null = null
let requested = false
const sessionReady = computed(() => session.status === 'ready')
const canManage = computed(
() => session.session?.role === 'owner' || session.session?.role === 'admin',
)
const forbidden = computed(
() => (sessionReady.value && !canManage.value) || store.loadState === 'forbidden',
)
const ready = computed(() => store.loadState === 'ready')
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function loadVariants() {
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
const loading = store.load()
try {
await loading
} finally {
clearSkeletonTimer()
showSkeleton.value = false
}
}
watch(
[sessionReady, canManage],
([isReady, isAdmin]) => {
if (isReady && isAdmin && !requested) {
requested = true
loadVariants()
}
},
{ immediate: true },
)
onBeforeUnmount(clearSkeletonTimer)
function rowMenuButton(name: string) {
const label = t('Actions for {0}', [name])
return (
Array.from(document.querySelectorAll<HTMLElement>('button[aria-label]')).find(
(button) => button.getAttribute('aria-label') === label,
) ?? null
)
}
function rememberOpener(element?: HTMLElement | null) {
formOpener =
element ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null)
}
async function restoreFocus(element: HTMLElement | null) {
await nextTick()
if (element?.isConnected) {
element.focus()
}
}
function openCreate(initial: VariantFormValues = blankValues()) {
rememberOpener()
formTarget.value = { mode: 'create', initial }
}
function openTemplate(name: string) {
const template = VARIANT_TEMPLATES.find((entry) => entry.name === name)
if (template) {
openCreate(valuesFromTemplate(template))
}
}
function openEdit(name: string) {
const variant = store.find(name)
if (!variant) {
return
}
rememberOpener(rowMenuButton(name))
formTarget.value = { mode: 'edit', initial: valuesFromVariant(variant), variant }
}
function closeForm() {
formTarget.value = null
restoreFocus(formOpener)
}
function onSaved(result: { mode: FormMode; name: string; purgedFiles: number }) {
closeForm()
announceSuccess(() => {
if (result.mode === 'create') {
return t('Variant “{0}” added', [result.name])
}
if (result.purgedFiles === 0) {
return t('Variant “{0}” saved.', [result.name])
}
return result.purgedFiles === 1
? t('Variant “{0}” saved. 1 file purged.', [result.name])
: t('Variant “{0}” saved. {1} files purged.', [
result.name,
formatNumber(result.purgedFiles),
])
})
}
async function onToggle(name: string, value: boolean) {
try {
await store.toggle(name, value)
} catch (error) {
if (!isHandledByApiClient(error)) {
announceFailure(error)
}
}
}
function generatedFilesText(count: number) {
return count === 1 ? t('1 generated file') : t('{0} generated files', [formatNumber(count)])
}
async function onPurge(name: string) {
const variant = store.find(name)
if (!variant) {
return
}
const trigger = rowMenuButton(name)
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Purge cache for “{0}”?', [name])
},
get description() {
return t(
'This removes {0} ({1}). Files are generated again on the next request.',
[
generatedFilesText(variant.generated_files),
formatVariantSize(variant.cache_size),
],
)
},
get confirmLabel() {
return variant.generated_files === 1
? t('Purge 1 file')
: t('Purge {0} files', [formatNumber(variant.generated_files)])
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Purging…')
},
tone: 'neutral',
action: async () => undefined,
},
})
.open()
restoreFocus(trigger)
if (!confirmed) {
return
}
try {
const result = await store.purge(name)
announceSuccess(() =>
result.purged_files === 1
? t('Purged 1 file from “{0}”', [name])
: t('Purged {0} files from “{1}”', [formatNumber(result.purged_files), name]),
)
} catch (error) {
if (!isHandledByApiClient(error)) {
announceFailure(error)
}
}
}
async function onDelete(name: string) {
const variant = store.find(name)
if (!variant) {
return
}
const trigger = rowMenuButton(name)
let failure: unknown = null
let removed = false
await overlay
.create(ConfirmDialog, {
props: {
get title() {
return t('Delete variant “{0}”?', [name])
},
get description() {
return t(
"This removes the variant and its {0} ({1}). Articles that use ?v={2} will start returning 400. This can't be undone.",
[
generatedFilesText(variant.generated_files),
formatVariantSize(variant.cache_size),
name,
],
)
},
get confirmLabel() {
return t('Delete variant')
},
get cancelLabel() {
return t('Cancel')
},
get pendingLabel() {
return t('Delete variant')
},
tone: 'danger',
action: async () => {
try {
await store.remove(name)
removed = true
} catch (error) {
failure = error
}
},
},
})
.open()
if (removed) {
restoreFocus(document.querySelector<HTMLElement>('[data-testid="add-variant"]'))
announceSuccess(() => t('Variant “{0}” deleted', [name]))
return
}
restoreFocus(trigger)
if (failure === null || isHandledByApiClient(failure)) {
return
}
if (isStatus(failure, NOT_FOUND)) {
await store.reloadQuietly()
}
announceFailure(failure)
}
</script>
<template>
<DashboardShell compact-padding :mobile-title="t('Variants')" :no-current-nav="forbidden">
<template #breadcrumb>
<UBreadcrumb
:items="[
{
get label() {
return t('Settings')
},
to: '/settings',
},
{
get label() {
return t('Media')
},
},
{
get label() {
return t('Variants')
},
},
]"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<VariantsForbidden v-if="forbidden" @home="router.push('/home')" />
<div v-else-if="sessionReady" class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<h1 class="text-xl/8 font-semibold text-highlighted sm:text-stat">
{{ t('Image variants') }}
</h1>
<p class="max-w-155 text-pretty text-muted">
{{
t(
'Named sizes and formats generated from your original images. Originals are never changed.',
)
}}
</p>
</div>
<UButton
data-testid="add-variant"
icon="i-lucide-plus"
class="h-11 shrink-0 justify-center px-3 text-sm sm:h-8"
@click="openCreate()"
>
{{ t('Add variant') }}
</UButton>
</div>
<VariantsTabs />
<VariantsSkeleton
v-if="store.loadState === 'loading' && showSkeleton"
:desktop="isDesktop"
/>
<UAlert
v-else-if="store.loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load variants')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 sm:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadVariants"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<VariantsEmpty v-else-if="store.isEmpty" @add="openCreate()" @template="openTemplate" />
<template v-else-if="ready">
<VariantsTable
v-if="isDesktop"
:items="store.items"
:is-enabled="store.enabledOf"
:is-locked="store.isBusy"
:is-purging="store.isPurging"
@toggle="onToggle"
@edit="openEdit"
@purge="onPurge"
@delete="onDelete"
/>
<VariantCardList
v-else
:items="store.items"
:is-enabled="store.enabledOf"
:is-locked="store.isBusy"
:is-purging="store.isPurging"
@toggle="onToggle"
@edit="openEdit"
@purge="onPurge"
@delete="onDelete"
/>
<RequestReferenceCard />
</template>
</div>
<VariantFormSlideover
v-if="formTarget"
:mode="formTarget.mode"
:initial="formTarget.initial"
:variant="formTarget.variant"
@close="closeForm"
@saved="onSaved"
/>
</DashboardShell>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,933 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import UButton from '@nuxt/ui/components/Button.vue'
import UIcon from '@nuxt/ui/components/Icon.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { initializeLocale } from '@/i18n'
import {
LIST,
MB,
alertDialog,
buttonByLabel,
buttonIn,
calls,
click,
defaultFixture,
deferred,
findButton,
menuItem,
mountPage,
pageTitle,
openMenu,
pressEscape,
rowOf,
rows,
stubBrowser,
textOf,
toastCalls,
unmountPage,
variant,
variantsTable,
wrapper,
} from './variantsTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const toasts = vi.hoisted(() => ({ add: vi.fn() }))
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => toasts }
})
const FORBIDDEN_TEXT =
'Only admins can manage image variants. Ask an admin if you need one changed.'
const FOOTER = '6 variants · 1,284 generated files · 212 MB'
const SERVER_TEXT =
"The server couldn't finish. Try again. If it keeps happening, check the server log."
const NOT_FOUND_TEXT = 'This variant or image no longer exists. Refresh the page.'
const PATCH_HERO = 'PATCH /v1/image-variants/hero'
const PATCH_PRINT = 'PATCH /v1/image-variants/print'
const PURGE_HERO = 'POST /v1/image-variants/hero/purge'
const DELETE_HERO = 'DELETE /v1/image-variants/hero'
function summary() {
return document.body.querySelector('[data-testid="variants-summary"]')?.textContent?.trim()
}
function iconNames(root: ParentNode) {
return wrapper!
.findAllComponents(UIcon)
.filter((icon) => root.contains(icon.element))
.map((icon) => icon.props('name'))
}
function iconOf(button: HTMLElement) {
return wrapper!
.findAllComponents(UButton)
.find((component) => component.element === button)
?.props('icon')
}
function switchOf(name: string) {
return document.body.querySelector<HTMLButtonElement>(
`button[role="switch"][aria-label="Enable variant ${name}"]`,
)!
}
function statusOf(name: string) {
return textOf(rowOf(name)).match(/(Enabled|Disabled|Purging…)/)?.[1]
}
function expectForbidden() {
expect(document.body.textContent).toContain("You don't have access")
expect(document.body.textContent).toContain(FORBIDDEN_TEXT)
expect(findButton('Back to Home')!.disabled).toBe(false)
expect(pageTitle()).toBeNull()
expect(document.body.querySelector('[role="tablist"]')).toBeNull()
expect(variantsTable()).toBeUndefined()
expect(document.body.querySelector('[data-testid="variant-card"]')).toBeNull()
expect(document.body.querySelector('[data-testid="request-reference"]')).toBeNull()
expect(findButton('Add variant')).toBeUndefined()
expect(document.body.querySelector('nav[aria-label="Main"] [aria-current="page"]')).toBeNull()
}
beforeEach(() => {
toasts.add.mockClear()
localStorage.clear()
initializeLocale()
stubBrowser()
vi.mocked(useApi).mockReset()
})
afterEach(async () => {
vi.unstubAllGlobals()
await unmountPage()
localStorage.clear()
initializeLocale()
})
describe('Rule: Only admins and owners see the page', () => {
it('A session whose role is not owner or admin gets the Forbidden state: no role', async () => {
await mountPage({ role: null })
expectForbidden()
expect(calls(LIST)).toHaveLength(0)
expect(iconNames(document.body)).toContain('i-lucide-lock')
const back = findButton('Back to Home')!
expect(iconOf(back)).toBe('i-lucide-arrow-left')
})
it('A session whose role is not owner or admin gets the Forbidden state: owner answered 403', async () => {
await mountPage({ routes: { [LIST]: () => Promise.reject(new ApiError(403)) } })
expectForbidden()
expect(calls(LIST)).toHaveLength(1)
})
it('Back to Home leaves the page', async () => {
await mountPage({ role: null })
await click(findButton('Back to Home'))
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
})
it.each(['owner', 'admin'])('An owner or an admin sees the list: %s', async (role) => {
await mountPage({ role })
expect(calls(LIST)).toHaveLength(1)
expect(pageTitle()?.textContent).toBe('Image variants')
expect(
document.body.querySelector('nav[aria-label="Main"] [aria-current="page"]')
?.textContent,
).toContain('Variants')
})
})
describe('Rule: Header and tabs', () => {
it('The page header and the tabs', async () => {
await mountPage()
expect(pageTitle()?.textContent).toBe('Image variants')
expect(document.body.textContent).toContain(
'Named sizes and formats generated from your original images. Originals are never changed.',
)
const add = findButton('Add variant')!
expect(iconOf(add)).toBe('i-lucide-plus')
const tabs = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]'))
expect(tabs.map((tab) => tab.textContent?.trim())).toEqual(['Variants', 'Cache'])
expect(tabs[0]!.getAttribute('aria-selected')).toBe('true')
expect(tabs[0]!.className).toContain('border-primary')
expect(tabs[0]!.className).toContain('font-medium')
expect(iconOf(tabs[0]!)).toBe('i-lucide-layers')
expect(iconOf(tabs[1]!)).toBe('i-lucide-database')
expect(tabs[1]!.getAttribute('aria-disabled')).toBe('true')
expect(tabs[1]!.className).toContain('text-toned')
})
it('Activating Cache changes nothing', async () => {
await mountPage()
const before = router.currentRoute.value.fullPath
const cache = document.body.querySelectorAll<HTMLElement>('[role="tab"]')[1]!
await click(cache)
expect(router.currentRoute.value.fullPath).toBe(before)
expect(cache.getAttribute('aria-selected')).toBe('false')
expect(calls(LIST)).toHaveLength(1)
})
it('The desktop breadcrumb reads Settings, Media, Variants', async () => {
await mountPage()
const items = Array.from(
document.body.querySelectorAll('nav[aria-label="breadcrumb"] li'),
).map((item) => item.textContent?.trim())
expect(items.filter(Boolean)).toEqual(['Settings', 'Media', 'Variants'])
})
it('The mobile top bar title is Variants', async () => {
await mountPage({ desktop: false })
expect(
document.body.querySelector('header h1, [data-slot="title"]')?.textContent,
).toContain('Variants')
})
it('The Add variant button sits below the description on mobile', async () => {
await mountPage({ desktop: false })
const add = findButton('Add variant')!
expect(add.className).toContain('h-11')
expect(add.parentElement!.className).toContain('flex-col')
})
})
describe('Rule: Loaded, table on desktop and cards on mobile', () => {
it('Desktop table', async () => {
await mountPage()
const headers = Array.from(variantsTable()!.querySelectorAll('thead th')).map((cell) =>
cell.textContent?.trim(),
)
expect(headers).toEqual([
'Name',
'Dimensions',
'Fit',
'Format',
'Quality',
'Generated',
'Cache size',
'Status',
'',
])
expect(rows().map(textOf)).toEqual([
'card 800 × auto inside WebP 80 318 58 MB Enabled',
'hero 1200×630 cover WebP 82 244 84 MB Enabled',
'og 1200×630 cover JPEG 80 96 9 MB Enabled',
'print auto × 2400 inside PNG — 74 21 MB Disabled',
'square 600×600 cover JPEG 85 140 19 MB Enabled',
'thumb 400×300 cover WebP 80 412 21 MB Enabled',
])
expect(summary()).toBe(FOOTER)
expect(document.body.querySelector('[data-testid="request-reference"]')).not.toBeNull()
})
it('The name and dimensions cells are IBM Plex Mono and the disabled row is muted', async () => {
await mountPage()
const cells = rowOf('hero').querySelectorAll('td')
expect(cells[0]!.querySelector('span')!.className).toContain('font-mono')
expect(cells[0]!.querySelector('span')!.className).toContain('font-medium')
expect(cells[1]!.querySelector('span')!.className).toContain('font-mono')
expect(rowOf('print').className).toContain('text-muted')
expect(rowOf('hero').className).toContain('text-default')
})
it('Mobile cards', async () => {
await mountPage({ desktop: false })
const cards = rows()
expect(cards).toHaveLength(6)
expect(textOf(rowOf('hero'))).toBe(
'hero Enabled 1200×630 · cover · WebP · q82 244 generated · 84 MB',
)
expect(textOf(rowOf('print'))).toContain('auto × 2400 · inside · PNG ')
expect(textOf(rowOf('print'))).not.toContain('q')
expect(summary()).toBe(FOOTER)
const footer = document.body.querySelector('[data-testid="variants-summary"]')!
const reference = document.body.querySelector('[data-testid="request-reference"]')!
expect(cards[5]!.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
)
expect(footer.compareDocumentPosition(reference) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
)
})
it('Singular footer', async () => {
await mountPage({
routes: {
[LIST]: () =>
Promise.resolve({
items: [
variant({ name: 'thumb', generated_files: 1, cache_size: 18 * 1024 }),
],
}),
},
})
expect(summary()).toBe('1 variant · 1 generated file · 18 KB')
})
it('Footer sums are computed from the list', async () => {
await mountPage()
expect(summary()).toBe(FOOTER)
})
})
describe('Rule: Loading', () => {
it('Skeletons appear only after 150 ms', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ routes: { [LIST]: () => new Promise(() => undefined) } })
vi.advanceTimersByTime(149)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(pageTitle()?.textContent).toBe('Image variants')
expect(document.body.querySelector('[role="tablist"]')).not.toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
const region = document.body.querySelector('section[aria-busy="true"]')!
expect(region.getAttribute('aria-label')).toBe('Loading variants')
expect(region.querySelectorAll('.h-12')).toHaveLength(5)
expect(region.querySelectorAll('.h-9')).toHaveLength(1)
expect(region.querySelectorAll('.h-12 [class*="animate-pulse"]')).toHaveLength(30)
expect(textOf(region)).toBe('')
})
it('Skeleton cards on mobile hold three bars', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ desktop: false, routes: { [LIST]: () => new Promise(() => undefined) } })
vi.advanceTimersByTime(150)
await flushPromises()
const cards = document.body.querySelectorAll('section[aria-busy="true"] > div > div')
expect(cards).toHaveLength(4)
expect(
Array.from(cards[0]!.children).map(
(bar) => (bar as HTMLElement).className.match(/w-\[\d+%\]/)?.[0],
),
).toEqual(['w-[40%]', 'w-[70%]', 'w-[55%]'])
})
it('A fast answer never flashes skeletons', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const pending = deferred<unknown>()
await mountPage({ routes: { [LIST]: () => pending.promise } })
vi.advanceTimersByTime(100)
pending.resolve({ items: defaultFixture() })
await flushPromises()
vi.advanceTimersByTime(500)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(rows()).toHaveLength(6)
})
})
describe('Rule: Empty', () => {
const emptyRoutes = { [LIST]: () => Promise.resolve({ items: [] }) }
it('No variants', async () => {
await mountPage({ routes: emptyRoutes })
expect(pageTitle()?.textContent).toBe('Image variants')
expect(document.body.querySelector('[role="tablist"]')).not.toBeNull()
expect(document.body.textContent).toContain('No variants yet')
expect(document.body.textContent).toContain(
'A variant is a named size that an image can be requested in, like ?v=thumb. Start with one of these.',
)
expect(iconNames(document.body)).toContain('i-lucide-layers')
const templates = Array.from(document.body.querySelectorAll('button')).filter((button) =>
button.textContent?.includes('Use template'),
)
expect(templates.map(textOf)).toEqual([
'thumb 400×300 · cover · WebP Use template',
'hero 1200×630 · cover · WebP Use template',
])
expect(templates.map((button) => button.className.includes('min-h-16'))).toEqual([
true,
true,
])
const adds = Array.from(document.body.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Add variant',
)
expect(adds).toHaveLength(2)
expect(variantsTable()).toBeUndefined()
expect(document.body.querySelector('[data-testid="variants-summary"]')).toBeNull()
expect(document.body.querySelector('[data-testid="request-reference"]')).toBeNull()
})
it.each([
['thumb', '400', '300', '80'],
['hero', '1200', '630', '82'],
])('A template opens the form pre-filled: %s', async (name, width, height, quality) => {
await mountPage({ routes: emptyRoutes })
const template = Array.from(document.body.querySelectorAll('button')).find((button) =>
button.textContent?.startsWith(name),
)!
await click(template)
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain('Add variant')
const form = document.body.querySelector('[role="dialog"]')!
expect((form.querySelector('[data-field="name"] input') as HTMLInputElement).value).toBe(
name,
)
expect((form.querySelector('[data-field="width"] input') as HTMLInputElement).value).toBe(
width,
)
expect((form.querySelector('[data-field="height"] input') as HTMLInputElement).value).toBe(
height,
)
expect((form.querySelector('[data-field="quality"] input') as HTMLInputElement).value).toBe(
quality,
)
expect(buttonIn(form, 'Add variant')!.disabled).toBe(false)
})
})
describe('Rule: Error loading', () => {
it.each([
['status 500', () => Promise.reject(new ApiError(500, undefined, 'boom'))],
['a network failure', () => Promise.reject(new NetworkError())],
])('A failed list request: %s', async (_name, failure) => {
await mountPage({ routes: { [LIST]: failure } })
expect(pageTitle()?.textContent).toBe('Image variants')
expect(document.body.querySelector('[role="tablist"]')).not.toBeNull()
const alert = document.body.querySelector<HTMLElement>('[role="alert"]')!
expect(textOf(alert)).toContain("Couldn't load variants")
expect(textOf(alert)).toContain('Check your connection and try again.')
expect(textOf(alert)).not.toContain('boom')
const retry = buttonIn(alert, 'Try again')!
expect(iconOf(retry)).toBe('i-lucide-rotate-cw')
expect(alert.querySelector('[data-slot="close"]')).toBeNull()
expect(variantsTable()).toBeUndefined()
expect(document.body.querySelector('[data-testid="variant-card"]')).toBeNull()
expect(document.body.querySelector('[data-testid="variants-summary"]')).toBeNull()
expect(document.body.querySelector('[data-testid="request-reference"]')).toBeNull()
})
it('Try again reloads', async () => {
let attempt = 0
await mountPage({
routes: {
[LIST]: () =>
++attempt === 1
? Promise.reject(new ApiError(500))
: Promise.resolve({ items: defaultFixture() }),
},
})
await click(findButton('Try again'))
expect(calls(LIST)).toHaveLength(2)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(rows()).toHaveLength(6)
})
})
describe('Rule: Status switch', () => {
it('Each row has a labelled switch paired with an icon and a word', async () => {
await mountPage()
expect(document.body.querySelectorAll('button[role="switch"]')).toHaveLength(6)
expect(switchOf('hero').getAttribute('aria-checked')).toBe('true')
expect(switchOf('print').getAttribute('aria-checked')).toBe('false')
expect(iconNames(rowOf('hero'))).toContain('i-lucide-circle-check')
expect(iconNames(rowOf('print'))).toContain('i-lucide-circle-off')
expect(switchOf('hero').className).toContain('w-8')
expect(switchOf('hero').className).toContain('h-4.5')
})
it.each([
['hero', 'enabled', PATCH_HERO, false, 'Disabled'],
['print', 'disabled', PATCH_PRINT, true, 'Enabled'],
])(
'Toggling sends PATCH and updates the row: %s is %s',
async (name, _before, route, after, label) => {
const pending = deferred<unknown>()
const original = defaultFixture().find((item) => item.name === name)!
await mountPage({ routes: { [route]: () => pending.promise } })
await click(switchOf(name))
expect(JSON.parse(String((calls(route)[0]![1] as RequestInit).body))).toEqual({
enabled: after,
})
expect(switchOf(name).disabled).toBe(true)
pending.resolve({ ...original, enabled: after })
await flushPromises()
expect(statusOf(name)).toBe(label)
expect(switchOf(name).disabled).toBe(false)
expect(textOf(rowOf(name))).toContain(`${original.generated_files}`)
expect(calls(`POST /v1/image-variants/${name}/purge`)).toHaveLength(0)
expect(toasts.add).not.toHaveBeenCalled()
},
)
it('A toggle shows the response generated_files and cache_size', async () => {
await mountPage({
routes: {
[PATCH_HERO]: () =>
Promise.resolve(
variant({ enabled: false, generated_files: 5, cache_size: 2 * MB }),
),
},
})
await click(switchOf('hero'))
expect(textOf(rowOf('hero'))).toBe('hero 1200×630 cover WebP 82 5 2 MB Disabled')
})
it.each([
[403, "You don't have access", 'Only admins can manage image variants.'],
[404, 'Not found', NOT_FOUND_TEXT],
[500, 'Something went wrong', SERVER_TEXT],
])('A failed toggle reverts and reports: %s', async (status, title, text) => {
await mountPage({ routes: { [PATCH_HERO]: () => Promise.reject(new ApiError(status)) } })
await click(switchOf('hero'))
expect(statusOf('hero')).toBe('Enabled')
expect(switchOf('hero').getAttribute('aria-checked')).toBe('true')
expect(switchOf('hero').disabled).toBe(false)
expect(toastCalls(toasts)).toEqual([{ title, description: text, color: 'error' }])
})
})
describe('Rule: Row menu', () => {
it('The ellipsis opens a three-item menu', async () => {
await mountPage()
const button = buttonByLabel('Actions for hero')!
expect(button.getAttribute('aria-label')).toBe('Actions for hero')
await click(button)
const menu = document.body.querySelector<HTMLElement>('[role="menu"]')!
expect(menu.className).toContain('w-44')
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]'))
expect(items.map((item) => item.textContent?.trim())).toEqual([
'Edit',
'Purge cache',
'Delete',
])
expect(items.every((item) => item.className.includes('h-8'))).toBe(true)
expect(items.every((item) => item.className.includes('max-sm:h-11'))).toBe(true)
expect(items[2]!.className).toContain('text-error')
expect(button.className).toContain('data-[state=open]:outline-primary')
expect(button.getAttribute('data-state')).toBe('open')
})
it('Menu keyboard behaviour: Escape closes the menu with focus back on the ellipsis button', async () => {
await mountPage()
const button = buttonByLabel('Actions for hero')!
button.focus()
await click(button)
await pressEscape()
await vi.waitFor(() => expect(document.body.querySelector('[role="menu"]')).toBeNull())
await vi.waitFor(() => expect(document.activeElement).toBe(button))
})
it('Menu items: Edit opens the slide-over in edit mode', async () => {
await mountPage()
await openMenu('hero')
await click(menuItem('Edit'))
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain(
'Edit variant: hero',
)
})
it('Menu items: Purge cache opens the purge dialog', async () => {
await mountPage()
await openMenu('hero')
await click(menuItem('Purge cache'))
expect(alertDialog()?.textContent).toContain('Purge cache for “hero”?')
})
it('Menu items: Delete opens the delete dialog', async () => {
await mountPage()
await openMenu('hero')
await click(menuItem('Delete'))
expect(alertDialog()?.textContent).toContain('Delete variant “hero”?')
})
})
describe('Rule: Purge cache', () => {
async function openPurge() {
await openMenu('hero')
await click(menuItem('Purge cache'))
}
it('The purge dialog', async () => {
await mountPage()
await openPurge()
const dialog = alertDialog()!
expect(textOf(dialog)).toContain('Purge cache for “hero”?')
expect(textOf(dialog)).toContain(
'This removes 244 generated files (84 MB). Files are generated again on the next request.',
)
const buttons = Array.from(dialog.querySelectorAll('button')).map((button) =>
button.textContent?.trim(),
)
expect(buttons).toEqual(['Cancel', 'Purge 244 files'])
expect(dialog.contains(document.activeElement)).toBe(true)
})
it('Cancel closes without a request', async () => {
await mountPage()
await openPurge()
await click(buttonIn(alertDialog()!, 'Cancel'))
expect(alertDialog()).toBeNull()
expect(calls(PURGE_HERO)).toHaveLength(0)
expect(textOf(rowOf('hero'))).toContain('244 84 MB')
expect(document.activeElement).toBe(buttonByLabel('Actions for hero'))
})
it('Escape closes the dialog without a request', async () => {
await mountPage()
await openPurge()
await pressEscape()
expect(alertDialog()).toBeNull()
expect(calls(PURGE_HERO)).toHaveLength(0)
})
it('Confirming purges while the row is dimmed', async () => {
const pending = deferred<unknown>()
await mountPage({ routes: { [PURGE_HERO]: () => pending.promise } })
await openPurge()
await click(buttonIn(alertDialog()!, 'Purge 244 files'))
expect(calls(PURGE_HERO)).toHaveLength(1)
expect(alertDialog()).toBeNull()
expect(rowOf('hero').className).toContain('bg-elevated')
expect(switchOf('hero').disabled).toBe(true)
expect(buttonByLabel('Actions for hero')!.disabled).toBe(true)
expect(statusOf('hero')).toBe('Purging…')
expect(iconNames(rowOf('hero'))).toContain('i-lucide-loader-circle')
expect(rowOf('hero').querySelector('.text-info')).not.toBeNull()
expect(switchOf('card').disabled).toBe(false)
expect(buttonByLabel('Actions for card')!.disabled).toBe(false)
pending.resolve({ purged_files: 244, purged_bytes: 88080384 })
await flushPromises()
})
it('Purge succeeds', async () => {
await mountPage({
routes: {
[PURGE_HERO]: () => Promise.resolve({ purged_files: 244, purged_bytes: 88080384 }),
},
})
await openPurge()
await click(buttonIn(alertDialog()!, 'Purge 244 files'))
expect(textOf(rowOf('hero'))).toBe('hero 1200×630 cover WebP 82 0 0 KB Enabled')
expect(switchOf('hero').disabled).toBe(false)
expect(buttonByLabel('Actions for hero')!.disabled).toBe(false)
expect(summary()).toBe('6 variants · 1,040 generated files · 128 MB')
expect(calls(LIST)).toHaveLength(1)
expect(toastCalls(toasts)).toEqual([
{ title: 'Purged 244 files from “hero”', description: undefined, color: 'success' },
])
})
it.each([
[404, 'Not found', NOT_FOUND_TEXT],
[500, 'Something went wrong', SERVER_TEXT],
])('Purge fails: %s', async (status, title, text) => {
await mountPage({ routes: { [PURGE_HERO]: () => Promise.reject(new ApiError(status)) } })
await openPurge()
await click(buttonIn(alertDialog()!, 'Purge 244 files'))
expect(textOf(rowOf('hero'))).toBe('hero 1200×630 cover WebP 82 244 84 MB Enabled')
expect(switchOf('hero').disabled).toBe(false)
expect(buttonByLabel('Actions for hero')!.disabled).toBe(false)
expect(toastCalls(toasts)).toEqual([{ title, description: text, color: 'error' }])
})
})
describe('Rule: Delete variant', () => {
async function openDelete(name = 'hero') {
await openMenu(name)
await click(menuItem('Delete'))
}
it('The delete dialog', async () => {
await mountPage()
await openDelete()
const dialog = alertDialog()!
expect(textOf(dialog)).toContain('Delete variant “hero”?')
expect(textOf(dialog)).toContain(
"This removes the variant and its 244 generated files (84 MB). Articles that use ?v=hero will start returning 400. This can't be undone.",
)
const buttons = Array.from(dialog.querySelectorAll('button'))
expect(buttons.map((button) => button.textContent?.trim())).toEqual([
'Cancel',
'Delete variant',
])
expect(buttons[1]!.className).toContain('bg-error')
})
it('Confirming shows a spinner and blocks dismissal', async () => {
const pending = deferred<unknown>()
await mountPage({ routes: { [DELETE_HERO]: () => pending.promise } })
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(calls(DELETE_HERO)).toHaveLength(1)
const dialog = alertDialog()!
expect(buttonIn(dialog, 'Cancel')!.disabled).toBe(true)
const confirm = buttonIn(dialog, 'Delete variant')!
expect(iconNames(confirm)).toContain('i-lucide-loader-circle')
await pressEscape()
expect(alertDialog()).not.toBeNull()
pending.resolve(undefined)
await flushPromises()
})
it('Delete succeeds', async () => {
await mountPage({ routes: { [DELETE_HERO]: () => Promise.resolve(undefined) } })
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(alertDialog()).toBeNull()
expect(rows().map((row) => textOf(row).split(' ')[0])).toEqual([
'card',
'og',
'print',
'square',
'thumb',
])
expect(summary()).toBe('5 variants · 1,040 generated files · 128 MB')
expect(toastCalls(toasts)).toEqual([
{ title: 'Variant “hero” deleted', description: undefined, color: 'success' },
])
})
it('Deleting the last variant shows the empty state', async () => {
await mountPage({
routes: {
[LIST]: () => Promise.resolve({ items: [variant({ name: 'thumb' })] }),
'DELETE /v1/image-variants/thumb': () => Promise.resolve(undefined),
},
})
await openDelete('thumb')
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(document.body.textContent).toContain('No variants yet')
expect(variantsTable()).toBeUndefined()
})
it('Delete fails: 404 reloads the list and reports', async () => {
let listCalls = 0
await mountPage({
routes: {
[LIST]: () => {
listCalls++
return Promise.resolve({
items:
listCalls === 1
? defaultFixture()
: defaultFixture().filter((v) => v.name !== 'hero'),
})
},
[DELETE_HERO]: () => Promise.reject(new ApiError(404)),
},
})
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(alertDialog()).toBeNull()
expect(calls(LIST)).toHaveLength(2)
expect(toastCalls(toasts)).toEqual([
{ title: 'Not found', description: NOT_FOUND_TEXT, color: 'error' },
])
})
it('Delete fails: 500 keeps the row and reports', async () => {
await mountPage({ routes: { [DELETE_HERO]: () => Promise.reject(new ApiError(500)) } })
await openDelete()
await click(buttonIn(alertDialog()!, 'Delete variant'))
expect(alertDialog()).toBeNull()
expect(rows()).toHaveLength(6)
expect(calls(LIST)).toHaveLength(1)
expect(toastCalls(toasts)).toEqual([
{ title: 'Something went wrong', description: SERVER_TEXT, color: 'error' },
])
})
})
describe('Rule: Request reference card', () => {
it('The card under the table', async () => {
await mountPage()
const card = document.body.querySelector<HTMLElement>('[data-testid="request-reference"]')!
expect(card.querySelector('h2')?.textContent).toBe('How requests are handled')
const cardRows = Array.from(card.querySelectorAll('tbody tr')).map(textOf)
expect(cardRows).toEqual([
'/static/uploadedImages/photo.png?v=thumb 200 Image',
'Unknown or disabled variant 400 Unknown variant',
'width, height, format or quality 400 Unsupported parameter',
'Missing source image 404 Not found',
])
expect(card.querySelector('tbody tr td span')!.className).toContain('font-mono')
const badges = Array.from(card.querySelectorAll('tbody tr td:nth-child(2) > *'))
expect(badges.map((badge) => badge.className.includes('bg-(--rc-success-soft)'))).toEqual([
true,
false,
false,
false,
])
expect(
badges.slice(1).every((badge) => badge.className.includes('bg-(--rc-error-soft)')),
).toBe(true)
expect(iconNames(card)).toEqual(
expect.arrayContaining(['i-lucide-circle-check', 'i-lucide-circle-x']),
)
expect(textOf(card.querySelector('div.flex.flex-col.gap-1\\.5')!)).toContain(
'Body of a 400 response',
)
expect(card.querySelector('pre')!.textContent).toBe(
'{\n "status": 400,\n "error": "unknown_variant",\n "message": "Unknown variant",\n "detail": "banner"\n}',
)
expect(card.querySelector('p')!.textContent).toBe(
'unsupported_parameter replaces unknown_variant when a raw parameter is sent. There is no arbitrary-size mode.',
)
})
it('The card has one column on mobile', async () => {
await mountPage({ desktop: false })
const grid = document.body.querySelector<HTMLElement>(
'[data-testid="request-reference"] .grid',
)!
expect(grid.className).toContain('grid-cols-1')
expect(grid.className).toContain('sm:grid-cols-')
})
it.each([
['Loading', { [LIST]: () => new Promise(() => undefined) }],
['Empty', { [LIST]: () => Promise.resolve({ items: [] }) }],
['Error', { [LIST]: () => Promise.reject(new ApiError(500)) }],
])('The card is not drawn when the state is %s', async (_state, routes) => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ routes })
vi.advanceTimersByTime(200)
await flushPromises()
expect(document.body.querySelector('[data-testid="request-reference"]')).toBeNull()
})
})
describe('Rule: Accessibility and hygiene', () => {
it('Status is never colour alone', async () => {
await mountPage()
for (const item of defaultFixture()) {
const row = rowOf(item.name)
expect(statusOf(item.name)).toMatch(/^(Enabled|Disabled|Purging…)$/)
expect(iconNames(row).some((name) => /circle-(check|off)/.test(name))).toBe(true)
}
})
it('Touch targets on mobile', async () => {
await mountPage({ desktop: false })
expect(buttonByLabel('Actions for hero')!.className).toContain('max-sm:size-11')
expect(findButton('Add variant')!.className).toContain('h-11')
expect(switchOf('hero').className).toContain('before:-inset-3.5')
expect(document.body.querySelectorAll('[role="tab"]')[0]!.className).toContain(
'max-sm:h-11',
)
})
it('Vocabulary', async () => {
await mountPage()
await openMenu('hero')
const text = document.body.textContent!.toLowerCase()
expect(text).not.toContain('thumbnail preset')
expect(text).not.toContain('rendition')
expect(text).not.toMatch(/\bversion\b/)
})
})
describe('Rule: French strings', () => {
it('renders the list in French with no English left over', async () => {
const { setLocale } = await import('@/i18n')
setLocale('fr')
await mountPage()
expect(pageTitle()?.textContent).toBe('Variantes d’image')
expect(rows().map((row) => textOf(row).split(' ')[0])).toEqual([
'card',
'hero',
'og',
'print',
'square',
'thumb',
])
expect(summary()).toBe('6 variantes · 1 284 fichiers générés · 212 Mo')
expect(textOf(rowOf('print'))).toContain('Désactivée')
expect(document.body.textContent).toContain('Traitement des requêtes')
const text = document.body.textContent!
for (const english of [
'Image variants',
'generated files',
'How requests',
'Enabled',
'Disabled',
'Add variant',
'Cache size',
]) {
expect(text).not.toContain(english)
}
})
})
+350
View File
@@ -0,0 +1,350 @@
import { vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import type { ImageVariant } from '@/composables/useImageVariants'
import { useSessionStore } from '@/stores/session'
import VariantsPage from '../VariantsPage.vue'
export const MB = 1048576
export const LIST = 'GET /v1/image-variants'
export const CREATE = 'POST /v1/image-variants'
export type Handler = (body?: Record<string, unknown>) => Promise<unknown>
export interface MountOptions {
desktop?: boolean
role?: string | null
routes?: Record<string, Handler>
}
export function variant(overrides: Partial<ImageVariant>): ImageVariant {
return {
name: 'hero',
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
generate_on_upload: false,
enabled: true,
generated_files: 244,
cache_size: 84 * MB,
created_at: '2026-03-12T09:00:00Z',
updated_at: '2026-03-12T09:00:00Z',
...overrides,
}
}
export function defaultFixture(): ImageVariant[] {
return [
variant({
name: 'card',
width: 800,
height: null,
fit: 'inside',
quality: 80,
generated_files: 318,
cache_size: 58 * MB,
}),
variant({}),
variant({
name: 'og',
format: 'jpeg',
quality: 80,
generated_files: 96,
cache_size: 9 * MB,
}),
variant({
name: 'print',
width: null,
height: 2400,
fit: 'inside',
format: 'png',
quality: null,
enabled: false,
generated_files: 74,
cache_size: 21 * MB,
}),
variant({
name: 'square',
width: 600,
height: 600,
format: 'jpeg',
quality: 85,
generated_files: 140,
cache_size: 19 * MB,
}),
variant({
name: 'thumb',
width: 400,
height: 300,
quality: 80,
generated_files: 412,
cache_size: 21 * MB,
}),
]
}
export function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
export function setViewport(desktop: boolean) {
window.matchMedia = ((query: string) => ({
matches: desktop,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia
}
export let requestMock: ReturnType<typeof vi.fn>
export let wrapper: VueWrapper | null = null
export async function mountPage(options: MountOptions = {}) {
const { desktop = true, role = 'owner', routes = {} } = options
setViewport(desktop)
setActivePinia(createPinia())
const session = useSessionStore()
const sessionRole = role ?? undefined
session.session = {
verified: true,
username: 'marta',
id: 1,
displayName: 'Marta Alves',
role: sessionRole as 'owner' | 'admin' | undefined,
}
session.status = 'ready'
requestMock = vi.fn((path: string, init?: RequestInit) => {
const key = `${init?.method ?? 'GET'} ${path}`
const handler =
routes[key] ??
(key === LIST ? () => Promise.resolve({ items: defaultFixture() }) : null)
if (!handler) {
throw new Error(`Unexpected request: ${key}`)
}
return handler(
init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : undefined,
)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi
.fn()
.mockResolvedValue({ verified: true, username: 'marta', id: 1, role: sessionRole }),
request: requestMock,
})
await router.push('/settings/media/variants')
const Host = defineComponent({ render: () => h(UApp, null, () => h(VariantsPage)) })
wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
export async function unmountPage() {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
document.body.innerHTML = ''
await router.push('/login')
}
export function textOf(element: Element) {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
const parts: string[] = []
while (walker.nextNode()) {
const text = walker.currentNode.textContent?.trim()
if (text) {
parts.push(text)
}
}
return parts.join(' ')
}
export function rows() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('tbody tr, [data-testid="variant-card"]'),
).filter((row) => !row.closest('[data-testid="request-reference"]'))
}
export function pageTitle() {
return document.body.querySelector('h1:not([data-slot="title"])')
}
export function variantsTable() {
return Array.from(document.body.querySelectorAll('table')).find(
(table) => !table.closest('[data-testid="request-reference"]'),
)
}
export function rowOf(name: string) {
return rows().find((row) => row.textContent?.includes(name))!
}
export function buttonIn(root: ParentNode, text: string) {
return Array.from(root.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
export function findButton(text: string) {
return buttonIn(document.body, text)
}
export function buttonByLabel(label: string) {
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
}
export async function click(element: HTMLElement | null | undefined) {
element?.click()
await flushPromises()
}
export function calls(key: string) {
return requestMock.mock.calls.filter(
([path, init]) => `${(init as RequestInit | undefined)?.method ?? 'GET'} ${path}` === key,
)
}
export function bodyOf(key: string, index = 0) {
return JSON.parse(String((calls(key)[index]![1] as RequestInit).body)) as Record<
string,
unknown
>
}
export function toastCalls(toasts: { add: ReturnType<typeof vi.fn> }) {
return toasts.add.mock.calls.map(([source]) => ({
title: (source as { title?: string }).title,
description: (source as { description?: string }).description,
color: (source as { color?: string }).color,
}))
}
export function dispatch(element: Element, type: string) {
element.dispatchEvent(new Event(type, { bubbles: true }))
}
export async function typeInto(input: HTMLInputElement | null | undefined, value: string) {
input!.value = value
dispatch(input!, 'input')
await flushPromises()
}
export async function blur(input: HTMLInputElement | null | undefined) {
dispatch(input!, 'blur')
await flushPromises()
}
export async function pressKey(element: Element, key: string, init: KeyboardEventInit = {}) {
element.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }),
)
await flushPromises()
}
export async function pressEscape() {
const target = document.activeElement ?? document.body
target.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await flushPromises()
}
export function dialogs() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('[role="dialog"], [role="alertdialog"]'),
)
}
export function alertDialog() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')
}
export function slideover() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
export function field(name: string) {
return document.body.querySelector<HTMLInputElement>(
`[data-field="${name}"] input:not([aria-hidden="true"])`,
)
}
export function fieldMessage(name: string) {
return document.body
.querySelector(`[data-field="${name}"] [data-slot="error"]`)
?.textContent?.trim()
}
export function fieldHelp(name: string) {
return document.body
.querySelector(`[data-field="${name}"] [data-slot="help"]`)
?.textContent?.trim()
}
export async function openMenu(name: string) {
await click(buttonByLabel(`Actions for ${name}`))
}
export function menuItem(label: string) {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
(item) => item.textContent?.trim() === label,
)
}
export async function openAddForm() {
const opener = document.body.querySelector<HTMLElement>('[data-testid="add-variant"]')!
opener.focus()
opener.click()
await flushPromises()
}
export async function openEditForm(name: string) {
await openMenu(name)
await click(menuItem('Edit'))
}
export function stubBrowser() {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
}
export async function clickBackdrop() {
const overlay = document.body.querySelector<HTMLElement>('[data-slot="overlay"]')!
await new Promise((resolve) => setTimeout(resolve, 20))
const down = new MouseEvent('pointerdown', { bubbles: true, button: 0 })
Object.defineProperty(down, 'pointerType', { value: 'mouse' })
overlay.dispatchEvent(down)
overlay.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
}
export function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
return writeText
}
+65
View File
@@ -186,7 +186,72 @@ function tableStates(markdown: string) {
.filter((cell) => cell !== 'State' && !/^-+$/.test(cell))
}
const variantsBoard = 'RenewCMS Screens Image variants.dc.html'
function numberedStates(prefix: string, names: string[]) {
return Object.fromEntries(
names.map((state, index) => [
state,
`${prefix}${String.fromCharCode(97 + index)} · ${state}`,
]),
)
}
function variantsScreen(): Screen {
const listNames = [
'Loaded',
'Loading',
'Empty',
'Error loading',
'Forbidden',
'Row menu',
'Row action in progress',
'Purge confirm',
'Delete confirm',
'Delete in progress',
'Request reference',
'Entry: sidebar',
]
const formNames = [
'Create: pristine',
'Create: dirty',
'Field errors',
'No dimension',
'Name already taken',
'Saving',
'Save error',
'Edit: pristine',
'Edit: dirty',
'Unsaved changes',
'PNG: no quality',
]
const sources: Source[] = [
{
board: variantsBoard,
group: 'Variants list',
states: listNames,
alias: numberedStates('1', listNames),
},
{
board: variantsBoard,
group: 'Add edit variant',
states: formNames,
alias: numberedStates('2', formNames),
},
]
return {
name: 'image-variants',
board: variantsBoard,
group: 'Variants list',
states: [...listNames, ...formNames],
sources,
}
}
async function readScreen(name: string): Promise<Screen> {
if (name === 'image-variants') {
return variantsScreen()
}
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
const reference = /\*\*Visual reference:\*\* `source\/([^`]+)`, group "([^"]+)"/.exec(markdown)
if (!reference) {
+334
View File
@@ -3095,6 +3095,339 @@ const activityStates: Record<string, VisualState> = {
Details: activityClockSensitive,
}
const variantsPath = '/settings/media/variants'
const variantsMegabyte = 1048576
const variantsSession: ApiMock = {
status: 200,
body: {
id: 1,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: 'Marta Alves',
role: 'owner',
},
}
function boardVariant(
name: string,
width: number | null,
height: number | null,
fit: string,
format: string,
quality: number | null,
files: number,
megabytes: number,
enabled = true,
) {
return {
name,
width,
height,
fit,
format,
quality,
generate_on_upload: false,
enabled,
generated_files: files,
cache_size: megabytes * variantsMegabyte,
created_at: '2026-03-12T09:00:00Z',
updated_at: '2026-03-12T09:00:00Z',
}
}
const variantsBoardList = [
boardVariant('thumb', 400, 300, 'cover', 'webp', 80, 412, 21),
boardVariant('card', 800, null, 'inside', 'webp', 80, 318, 58),
boardVariant('hero', 1200, 630, 'cover', 'webp', 82, 244, 84),
boardVariant('square', 600, 600, 'cover', 'jpeg', 85, 140, 19),
boardVariant('og', 1200, 630, 'cover', 'jpeg', 80, 96, 9),
boardVariant('print', null, 2400, 'inside', 'png', null, 74, 21, false),
]
function variantsApi(
items: unknown[] = variantsBoardList,
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': variantsSession,
'GET /v1/image-variants': { status: 200, body: { items } },
...overrides,
}
}
function variantsListState(
overrides: Record<string, ApiMock> = {},
steps?: VisualState['steps'],
items: unknown[] = variantsBoardList,
): VisualState {
return { path: variantsPath, api: variantsApi(items, overrides), steps }
}
async function openVariantMenu(page: Page, name: string) {
await page.getByRole('button', { name: `Actions for ${name}`, exact: true }).click()
await page.getByRole('menu').waitFor()
}
async function chooseVariantMenuItem(page: Page, name: string, item: string) {
await openVariantMenu(page, name)
await page.getByRole('menuitem', { name: item, exact: true }).click()
}
async function scrollVariantsPage(page: Page, offset: number) {
await page.evaluate((value) => {
const body = document
.querySelector<HTMLElement>('#dashboard-content')
?.closest<HTMLElement>('[data-slot="body"]')
if (body) body.scrollTop = value
}, offset)
await page.waitForTimeout(150)
}
async function scrollVariantsForm(page: Page, offset: number) {
await page.evaluate((value) => {
const body = document.querySelector<HTMLElement>('[role="dialog"] [data-slot="body"]')
if (body) body.scrollTop = value
}, offset)
await page.waitForTimeout(150)
}
function isMobileViewport(page: Page) {
return (page.viewportSize()?.width ?? 0) < 640
}
async function openVariantForm(page: Page) {
await page.getByRole('button', { name: 'Add variant', exact: true }).first().click()
await page.getByRole('dialog').waitFor()
}
function variantField(page: Page, name: string) {
return page.locator(`[data-field="${name}"] input:not([aria-hidden="true"])`)
}
async function pickVariantFormat(page: Page, label: string) {
await page.getByRole('dialog').getByRole('combobox').click()
await page.getByRole('option', { name: label, exact: true }).click()
}
interface VariantFormFill {
name: string
width: string
height: string
format: string
quality?: string
generate?: boolean
}
async function fillVariantForm(page: Page, values: VariantFormFill) {
await variantField(page, 'name').fill(values.name)
await variantField(page, 'width').fill(values.width)
await variantField(page, 'height').fill(values.height)
await pickVariantFormat(page, values.format)
if (values.quality !== undefined) {
await variantField(page, 'quality').fill(values.quality)
}
if (values.generate) {
await page.getByRole('switch', { name: 'Generate on upload', exact: true }).click()
}
}
const heroForm: VariantFormFill = {
name: 'hero',
width: '1200',
height: '630',
format: 'WebP',
quality: '82',
generate: true,
}
async function blurFocus(page: Page) {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await page.waitForTimeout(100)
}
async function pressVariantFormButton(page: Page, label: string) {
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click()
}
async function openEditHero(page: Page) {
await chooseVariantMenuItem(page, 'hero', 'Edit')
await page.getByRole('dialog').waitFor()
}
const variantsStates: Record<string, VisualState> = {
Loaded: variantsListState(),
Loading: {
path: variantsPath,
api: variantsApi(variantsBoardList, {
'GET /v1/image-variants': { status: 200, hang: true },
}),
},
Empty: variantsListState({}, undefined, []),
'Error loading': variantsListState({ 'GET /v1/image-variants': { status: 500 } }),
Forbidden: {
path: variantsPath,
api: {
'GET /v1/auth/me': {
status: 200,
body: { ...(variantsSession.body as object), role: 'user' },
},
},
},
'Row menu': variantsListState({}, (page) => openVariantMenu(page, 'hero')),
'Row action in progress': variantsListState(
{ 'POST /v1/image-variants/hero/purge': { status: 200, hang: true } },
async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Purge cache')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Purge 244 files' })
.click()
await page.mouse.move(0, 0)
},
),
'Purge confirm': variantsListState({}, async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Purge cache')
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}),
'Delete confirm': variantsListState({}, async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Delete')
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}),
'Delete in progress': variantsListState(
{ 'DELETE /v1/image-variants/hero': { status: 204, hang: true } },
async (page) => {
await chooseVariantMenuItem(page, 'hero', 'Delete')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete variant' })
.click()
await page.mouse.move(0, 0)
},
),
'Entry: sidebar': {
path: '/media',
origin: mediaOrigin,
api: mediaApi({ 'GET /v1/auth/me': variantsSession }),
steps: async (page) => {
if (isMobileViewport(page)) {
await page.locator('[data-slot="toggle"]').first().click()
}
await page
.locator(
'nav[aria-label="Main"]:visible a[href="/settings"] [data-slot="linkTrailing"]',
)
.click()
await page.mouse.move(0, 0)
await page.waitForTimeout(250)
},
},
'Request reference': {
...variantsListState({}, async (page) => {
if (isMobileViewport(page)) {
await scrollVariantsPage(page, 900)
}
}),
exception: {
maxPercent: 21,
reason: 'board draws /media/photo.png?v=thumb (2 lines) and the mandated /static/uploadedImages/photo.png?v=thumb wraps to 3; board scroll 900 is unreachable (page max ~861)',
approvedBy: 'user',
},
},
'Create: pristine': variantsListState({}, openVariantForm),
'Create: dirty': variantsListState({}, async (page) => {
await openVariantForm(page)
await fillVariantForm(page, heroForm)
await page.locator('input[type="range"]').focus()
if (isMobileViewport(page)) {
await scrollVariantsForm(page, 560)
}
}),
'Field errors': variantsListState({}, async (page) => {
await openVariantForm(page)
await fillVariantForm(page, {
name: 'Hero banner',
width: '9000',
height: '630',
format: 'WebP',
quality: '140',
})
await variantField(page, 'name').blur()
await variantField(page, 'width').blur()
await variantField(page, 'quality').blur()
await blurFocus(page)
}),
'No dimension': variantsListState({}, async (page) => {
await openVariantForm(page)
await variantField(page, 'name').fill('banner')
await pickVariantFormat(page, 'WebP')
await pressVariantFormButton(page, 'Add variant')
await blurFocus(page)
}),
'Name already taken': variantsListState(
{
'POST /v1/image-variants': {
status: 409,
body: 'A variant named “thumb” already exists. Choose another name.',
},
},
async (page) => {
await openVariantForm(page)
await fillVariantForm(page, {
name: 'thumb',
width: '400',
height: '300',
format: 'WebP',
quality: '80',
generate: true,
})
await pressVariantFormButton(page, 'Add variant')
await page.getByText('already exists').waitFor()
await blurFocus(page)
},
),
Saving: variantsListState(
{ 'POST /v1/image-variants': { status: 201, hang: true } },
async (page) => {
await openVariantForm(page)
await fillVariantForm(page, heroForm)
await pressVariantFormButton(page, 'Add variant')
await page.mouse.move(0, 0)
},
),
'Save error': variantsListState(
{ 'POST /v1/image-variants': { status: 500 } },
async (page) => {
await openVariantForm(page)
await fillVariantForm(page, heroForm)
await pressVariantFormButton(page, 'Add variant')
await page.getByText("Couldn't save the variant").waitFor()
await page.mouse.move(0, 0)
},
),
'Edit: pristine': variantsListState({}, openEditHero),
'Edit: dirty': variantsListState({}, async (page) => {
await openEditHero(page)
await variantField(page, 'quality').fill('70')
await blurFocus(page)
}),
'Unsaved changes': variantsListState({}, async (page) => {
await openEditHero(page)
await variantField(page, 'quality').fill('70')
await pressVariantFormButton(page, 'Cancel')
await page.getByRole('alertdialog').waitFor()
}),
'PNG: no quality': variantsListState({}, async (page) => {
await openVariantForm(page)
await fillVariantForm(page, { ...heroForm, quality: undefined })
await pickVariantFormat(page, 'PNG')
await blurFocus(page)
}),
}
export const visualStates: Record<string, Record<string, VisualState>> = {
activity: activityStates,
profile: {
@@ -4233,6 +4566,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: submitAcceptInvite('lea', 'limewash2026'),
},
},
'image-variants': variantsStates,
media: { ...mediaStates, ...mediaPurgeStates },
'media-picker': pickerStates,
}