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