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
@@ -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>
|
||||
Reference in new issue
Block a user