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

+99
View File
@@ -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,
}
}
+93
View File
@@ -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 }
}
+31
View File
@@ -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 }
}