feat: image variants settings screens

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

No files matched your search

@@ -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>