Files
RenewCMS/web/src/components/variants/VariantFormFields.vue
T

392 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import AppSwitch from '@/components/AppSwitch.vue'
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">
<AppSwitch
v-model="values.generateOnUpload"
:disabled="disabled"
aria-labelledby="generate-on-upload-label"
/>
</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">
<AppSwitch
v-model="values.enabled"
:disabled="disabled"
aria-labelledby="variant-enabled-label"
/>
</div>
</div>
</div>
</div>
</div>
</template>