395 lines
11 KiB
Vue
395 lines
11 KiB
Vue
<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>
|