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