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

@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import {
SAMPLE_SOURCE,
estimateLabel,
estimateVariant,
formatPixelSize,
type EstimateRequest,
} from '../variantEstimate'
import { outputSize } from '../variantOutput'
const SAMPLE = { width: 4032, height: 3024 }
function request(overrides: Partial<EstimateRequest>): EstimateRequest {
return {
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
...overrides,
}
}
function labelOf(overrides: Partial<EstimateRequest>) {
return estimateLabel(estimateVariant(request(overrides))!)
}
describe('Output size, never enlarged', () => {
it.each([
[1200, 630, 'cover', '1200×630'],
[800, null, 'cover', '800×600'],
[null, 2400, 'inside', '3200×2400'],
[600, 600, 'contain', '600×600'],
[1000, 1000, 'inside', '1000×750'],
[8000, 8000, 'cover', '4032×3024'],
] as const)('width %s height %s fit %s gives %s', (width, height, fit, expected) => {
const size = outputSize(SAMPLE, { width, height, fit })
expect(formatPixelSize(size!)).toBe(expected)
})
it('has no output without a dimension', () => {
expect(outputSize(SAMPLE, { width: null, height: null, fit: 'cover' })).toBeNull()
})
it('crops to the box ratio when the box is smaller than the source in one dimension', () => {
const size = outputSize(
{ width: 300, height: 200 },
{ width: 400, height: 150, fit: 'cover' },
)
expect(size).toEqual({ width: 300, height: 113 })
})
it('crops a square box out of the sample', () => {
const size = outputSize(SAMPLE, { width: 1000, height: 1000, fit: 'cover' })
expect(size).toEqual({ width: 1000, height: 1000 })
})
})
describe('Size estimate', () => {
it.each([
['hero', 1200, 630, 'cover', 'webp', 82, '1200×630 · about 96 KB'],
['hero', 1200, 630, 'cover', 'webp', 70, '1200×630 · about 71 KB'],
['hero', 1200, 630, 'cover', 'png', 82, '1200×630 · about 310 KB'],
['thumb', 400, 300, 'cover', 'webp', 80, '400×300 · about 18 KB'],
] as const)(
'%s %s×%s %s %s q%s reads %s',
(_name, width, height, fit, format, quality, expected) => {
expect(labelOf({ width, height, fit, format, quality })).toBe(expected)
},
)
it('gives a smaller or equal estimate for a lower quality', () => {
const bytes = (quality: number) => estimateVariant(request({ quality }))!.bytes
const sizes = [1, 20, 40, 60, 80, 100].map(bytes)
expect(sizes).toEqual([...sizes].sort((left, right) => left - right))
})
it('never exceeds the original size', () => {
const estimate = estimateVariant(
request({ width: 8000, height: 8000, format: 'png', quality: 100 }),
)
expect(estimate!.bytes).toBeLessThanOrEqual(SAMPLE_SOURCE.bytes)
})
it('is empty without a dimension', () => {
expect(estimateVariant(request({ width: null, height: null }))).toBeNull()
})
})
@@ -0,0 +1,156 @@
import { describe, expect, it } from 'vitest'
import {
VARIANT_TEMPLATES,
blankValues,
hasChanged,
lacksDimension,
sliderValue,
toDefinition,
toNewVariant,
validateField,
valuesFromTemplate,
valuesFromVariant,
type FieldName,
type VariantFormValues,
} from '../variantForm'
const NAME_ERROR = 'Use lowercase letters, numbers and dashes, 2 to 32 characters.'
const RANGE_ERROR = 'Enter a value from 1 to 8000.'
const QUALITY_ERROR = 'Enter a value from 1 to 100.'
function values(overrides: Partial<VariantFormValues>): VariantFormValues {
return { ...blankValues(), name: 'hero', width: '1200', height: '630', ...overrides }
}
describe('Field errors', () => {
it.each([
['name', { name: 'Hero banner' }, NAME_ERROR],
['name', { name: 'a' }, NAME_ERROR],
['name', { name: 'a'.repeat(33) }, NAME_ERROR],
['width', { width: '9000' }, RANGE_ERROR],
['width', { width: '0' }, RANGE_ERROR],
['height', { height: '8001' }, RANGE_ERROR],
['quality', { quality: '140' }, QUALITY_ERROR],
['quality', { quality: '0' }, QUALITY_ERROR],
] as [FieldName, Partial<VariantFormValues>, string][])(
'%s %j reads the message',
(field, overrides, message) => {
expect(validateField(field, values(overrides))).toBe(message)
},
)
it.each([
['name', { name: 'ab' }],
['name', { name: 'a-b-c' }],
['name', { name: 'a'.repeat(32) }],
['width', { width: '1' }],
['height', { height: '8000' }],
['quality', { quality: '1' }],
['quality', { quality: '100' }],
] as [FieldName, Partial<VariantFormValues>][])('%s %j shows no error', (field, overrides) => {
expect(validateField(field, values(overrides))).toBeUndefined()
})
it('does not validate the quality of a PNG', () => {
expect(validateField('quality', values({ format: 'png', quality: '999' }))).toBeUndefined()
})
it('treats an empty dimension as absent', () => {
expect(validateField('width', values({ width: '' }))).toBeUndefined()
expect(lacksDimension(values({ width: '', height: '' }))).toBe(true)
expect(lacksDimension(values({ width: '', height: '9' }))).toBe(false)
})
})
describe('Request body', () => {
it('builds the create body from the form', () => {
const body = toNewVariant(
values({ format: 'webp', quality: '82', generateOnUpload: true, enabled: true }),
)
expect(body).toEqual({
name: 'hero',
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
generate_on_upload: true,
enabled: true,
})
})
it('sends an empty dimension as null', () => {
const body = toNewVariant(values({ name: 'card', width: '800', height: '' }))
expect(body.width).toBe(800)
expect(body.height).toBeNull()
})
it('omits the quality of a PNG', () => {
const definition = toDefinition(values({ format: 'png', quality: '70' }))
expect('quality' in definition).toBe(false)
})
})
describe('Form values', () => {
it('starts blank with the documented defaults', () => {
expect(blankValues()).toEqual({
name: '',
width: '',
height: '',
fit: 'cover',
format: 'original',
quality: '80',
generateOnUpload: false,
enabled: true,
})
})
it.each([
['thumb', '400', '300', '80'],
['hero', '1200', '630', '82'],
])('template %s pre-fills %s×%s q%s as cover WebP', (name, width, height, quality) => {
const template = VARIANT_TEMPLATES.find((entry) => entry.name === name)!
expect(valuesFromTemplate(template)).toMatchObject({
name,
width,
height,
quality,
fit: 'cover',
format: 'webp',
generateOnUpload: false,
enabled: true,
})
})
it('reads a stored PNG variant with a default hidden quality', () => {
const form = valuesFromVariant({
name: 'print',
width: null,
height: 2400,
fit: 'inside',
format: 'png',
quality: null,
generate_on_upload: false,
enabled: false,
generated_files: 74,
cache_size: 0,
})
expect([form.width, form.height, form.quality]).toEqual(['', '2400', '80'])
})
it('detects a change against the baseline', () => {
expect(hasChanged(blankValues(), blankValues())).toBe(false)
expect(hasChanged({ ...blankValues(), name: 'x' }, blankValues())).toBe(true)
})
it('clamps the slider position', () => {
expect([sliderValue('60'), sliderValue('140'), sliderValue('0'), sliderValue('')]).toEqual([
60, 100, 1, 1,
])
})
})
@@ -0,0 +1,170 @@
import { afterEach, describe, expect, it } from 'vitest'
import { initializeLocale, setLocale } from '@/i18n'
import type { ImageVariant } from '@/composables/useImageVariants'
import {
formatDimensions,
formatLabel,
formatVariantSize,
generatedLine,
qualityCell,
specLine,
variantsSummary,
} from '../variantFormat'
const MB = 1048576
function variant(overrides: Partial<ImageVariant>): ImageVariant {
return {
name: 'hero',
width: 1200,
height: 630,
fit: 'cover',
format: 'webp',
quality: 82,
generate_on_upload: false,
enabled: true,
generated_files: 244,
cache_size: 84 * MB,
...overrides,
}
}
function defaultFixture(): ImageVariant[] {
return [
variant({
name: 'card',
width: 800,
height: null,
fit: 'inside',
quality: 80,
generated_files: 318,
cache_size: 58 * MB,
}),
variant({ name: 'hero' }),
variant({
name: 'og',
format: 'jpeg',
quality: 80,
generated_files: 96,
cache_size: 9 * MB,
}),
variant({
name: 'print',
width: null,
height: 2400,
fit: 'inside',
format: 'png',
quality: null,
enabled: false,
generated_files: 74,
cache_size: 21 * MB,
}),
variant({
name: 'square',
width: 600,
height: 600,
format: 'jpeg',
quality: 85,
generated_files: 140,
cache_size: 19 * MB,
}),
variant({
name: 'thumb',
width: 400,
height: 300,
quality: 80,
generated_files: 412,
cache_size: 21 * MB,
}),
]
}
afterEach(() => {
localStorage.clear()
initializeLocale()
})
describe('Dimensions, quality and format cells', () => {
it.each([
[400, 300, 'webp', 80, '400×300', 'WebP', '80'],
[800, null, 'jpeg', 80, '800 × auto', 'JPEG', '80'],
[null, 2400, 'png', null, 'auto × 2400', 'PNG', '—'],
[600, 600, 'original', 82, '600×600', 'Original', '82'],
] as const)(
'width %s height %s format %s quality %s reads %s, %s, %s',
(width, height, format, quality, dimensions, label, cell) => {
const entry = variant({ width, height, format, quality })
expect(formatDimensions(entry.width, entry.height)).toBe(dimensions)
expect(formatLabel(entry.format)).toBe(label)
expect(qualityCell(entry)).toBe(cell)
},
)
})
describe('Spec line of a mobile card', () => {
it('joins dimensions, fit, format and quality', () => {
expect(specLine(variant({}))).toBe('1200×630 · cover · WebP · q82')
})
it('has no quality part for print', () => {
const print = defaultFixture()[3]!
expect(specLine(print)).toBe('auto × 2400 · inside · PNG')
})
it('reads the generated count and size', () => {
expect(generatedLine(variant({}))).toBe('244 generated · 84 MB')
})
})
describe('Footer sums are computed from the list', () => {
it('sums the Default fixture', () => {
expect(variantsSummary(defaultFixture())).toBe(
'6 variants · 1,284 generated files · 212 MB',
)
})
it('uses the singular for one variant with one file', () => {
const thumb = variant({ name: 'thumb', generated_files: 1, cache_size: 18 * 1024 })
expect(variantsSummary([thumb])).toBe('1 variant · 1 generated file · 18 KB')
})
it('recomputes after a deletion', () => {
const rest = defaultFixture().filter((entry) => entry.name !== 'hero')
expect(variantsSummary(rest)).toBe('5 variants · 1,040 generated files · 128 MB')
})
it('reads an empty cache as 0 KB', () => {
expect(variantsSummary([variant({ generated_files: 0, cache_size: 0 })])).toBe(
'1 variant · 0 generated files · 0 KB',
)
})
})
describe('Variant size formatter', () => {
it.each([
[0, '0 KB'],
[1, '1 KB'],
[18 * 1024, '18 KB'],
[1023 * 1024, '1023 KB'],
[MB, '1 MB'],
[Math.round(3.9 * MB), '3.9 MB'],
[84 * MB, '84 MB'],
[88080384, '84 MB'],
[2048 * MB, '2 GB'],
])('formats %s bytes as %s', (bytes, expected) => {
expect(formatVariantSize(bytes)).toBe(expected)
})
it('uses French units and decimal comma', () => {
setLocale('fr')
expect([formatVariantSize(18 * 1024), formatVariantSize(Math.round(3.9 * MB))]).toEqual([
'18 Ko',
'3,9 Mo',
])
})
})
+47
View File
@@ -0,0 +1,47 @@
import { t } from '@/i18n'
import { ApiError } from '@/composables/useApi'
export interface ToastCopy {
title: string
description: string
}
const BAD_REQUEST = 400
const FORBIDDEN = 403
const NOT_FOUND = 404
export function toastCopyFor(error: unknown): ToastCopy {
const status = error instanceof ApiError ? error.status : 0
if (status === BAD_REQUEST) {
return {
title: t('Check the request'),
description: t("A value in the request isn't valid. Reload the page and try again."),
}
}
if (status === FORBIDDEN) {
return {
title: t("You don't have access"),
description: t('Only admins can manage image variants.'),
}
}
if (status === NOT_FOUND) {
return {
title: t('Not found'),
description: t('This variant or image no longer exists. Refresh the page.'),
}
}
return {
title: t('Something went wrong'),
description: t(
"The server couldn't finish. Try again. If it keeps happening, check the server log.",
),
}
}
export function isStatus(error: unknown, status: number) {
return error instanceof ApiError && error.status === status
}
export function isHandledByApiClient(error: unknown) {
return error instanceof ApiError && error.status === 401
}
+64
View File
@@ -0,0 +1,64 @@
import { t } from '@/i18n'
import type { VariantFormat } from '@/composables/useImageVariants'
import { formatVariantSize } from '@/lib/variants/variantFormat'
import { outputSize, type OutputRequest, type PixelSize } from '@/lib/variants/variantOutput'
export type { PixelSize } from '@/lib/variants/variantOutput'
const BYTES_PER_MB = 1024 * 1024
const FULL_QUALITY = 100
const PNG_BYTES_PER_PIXEL = 0.42
const SIZE_EXPONENT = 0.885
const QUALITY_EXPONENT = 1.9
const LOSSY_BASE: Record<Exclude<VariantFormat, 'png'>, number> = {
webp: 0.9,
jpeg: 1.4,
original: 1.4,
}
export const SAMPLE_SOURCE = {
filename: 'north-wall.jpg',
width: 4032,
height: 3024,
bytes: Math.round(3.9 * BYTES_PER_MB),
}
export interface EstimateRequest extends OutputRequest {
format: VariantFormat
quality: number
}
export interface VariantEstimate {
size: PixelSize
bytes: number
}
function rawBytes(pixels: number, request: EstimateRequest) {
if (request.format === 'png') {
return pixels * PNG_BYTES_PER_PIXEL
}
const quality = Math.min(FULL_QUALITY, Math.max(1, request.quality)) / FULL_QUALITY
return (
LOSSY_BASE[request.format] *
Math.pow(pixels, SIZE_EXPONENT) *
Math.pow(quality, QUALITY_EXPONENT)
)
}
export function estimateVariant(request: EstimateRequest): VariantEstimate | null {
const size = outputSize(SAMPLE_SOURCE, request)
if (size === null) {
return null
}
const bytes = Math.min(SAMPLE_SOURCE.bytes, rawBytes(size.width * size.height, request))
return { size, bytes }
}
export function formatPixelSize(size: PixelSize) {
return `${size.width}×${size.height}`
}
export function estimateLabel(estimate: VariantEstimate) {
return t('{0} · about {1}', [formatPixelSize(estimate.size), formatVariantSize(estimate.bytes)])
}
+154
View File
@@ -0,0 +1,154 @@
import { t } from '@/i18n'
import type {
ImageVariant,
NewVariantRequest,
VariantDefinition,
VariantFit,
VariantFormat,
} from '@/composables/useImageVariants'
export const NAME_MAX_LENGTH = 32
const DIMENSION_MIN = 1
const DIMENSION_MAX = 8000
export const QUALITY_MIN = 1
export const QUALITY_MAX = 100
const DEFAULT_QUALITY = 80
const NAME_PATTERN = /^[a-z0-9-]{2,32}$/
const WHOLE_NUMBER = /^\d+$/
export type FieldName = 'name' | 'width' | 'height' | 'quality'
export interface VariantFormValues {
name: string
width: string
height: string
fit: VariantFit
format: VariantFormat
quality: string
generateOnUpload: boolean
enabled: boolean
}
export type FieldErrors = Partial<Record<FieldName, string>> & { dimension?: string }
export const FIELD_ORDER: FieldName[] = ['name', 'width', 'height', 'quality']
export const VARIANT_TEMPLATES = [
{ name: 'thumb', width: 400, height: 300, quality: 80 },
{ name: 'hero', width: 1200, height: 630, quality: 82 },
] as const
export function blankValues(): VariantFormValues {
return {
name: '',
width: '',
height: '',
fit: 'cover',
format: 'original',
quality: String(DEFAULT_QUALITY),
generateOnUpload: false,
enabled: true,
}
}
export function valuesFromTemplate(
template: (typeof VARIANT_TEMPLATES)[number],
): VariantFormValues {
return {
...blankValues(),
name: template.name,
width: String(template.width),
height: String(template.height),
format: 'webp',
quality: String(template.quality),
}
}
export function valuesFromVariant(variant: ImageVariant): VariantFormValues {
return {
name: variant.name,
width: variant.width === null ? '' : String(variant.width),
height: variant.height === null ? '' : String(variant.height),
fit: variant.fit,
format: variant.format,
quality: String(variant.quality ?? DEFAULT_QUALITY),
generateOnUpload: variant.generate_on_upload,
enabled: variant.enabled,
}
}
export function hasChanged(current: VariantFormValues, initial: VariantFormValues) {
return (Object.keys(initial) as (keyof VariantFormValues)[]).some(
(key) => current[key] !== initial[key],
)
}
export function usesQuality(format: VariantFormat) {
return format !== 'png'
}
function wholeNumberInRange(text: string, min: number, max: number) {
const trimmed = text.trim()
if (!WHOLE_NUMBER.test(trimmed)) {
return null
}
const value = Number(trimmed)
return value >= min && value <= max ? value : null
}
export function parseDimension(text: string) {
return wholeNumberInRange(text, DIMENSION_MIN, DIMENSION_MAX)
}
function parseQuality(text: string) {
return wholeNumberInRange(text, QUALITY_MIN, QUALITY_MAX)
}
export function validateField(field: FieldName, values: VariantFormValues): string | undefined {
if (field === 'name') {
return NAME_PATTERN.test(values.name)
? undefined
: t('Use lowercase letters, numbers and dashes, 2 to 32 characters.')
}
if (field === 'quality') {
return !usesQuality(values.format) || parseQuality(values.quality) !== null
? undefined
: t('Enter a value from 1 to 100.')
}
const text = values[field]
return text.trim() === '' || parseDimension(text) !== null
? undefined
: t('Enter a value from 1 to 8000.')
}
export function lacksDimension(values: VariantFormValues) {
return values.width.trim() === '' && values.height.trim() === ''
}
export function sliderValue(text: string) {
const parsed = Number.parseInt(text, 10)
if (Number.isNaN(parsed)) {
return QUALITY_MIN
}
return Math.min(QUALITY_MAX, Math.max(QUALITY_MIN, parsed))
}
export function toDefinition(values: VariantFormValues): VariantDefinition {
const definition: VariantDefinition = {
width: parseDimension(values.width),
height: parseDimension(values.height),
fit: values.fit,
format: values.format,
generate_on_upload: values.generateOnUpload,
enabled: values.enabled,
}
if (usesQuality(values.format)) {
definition.quality = parseQuality(values.quality) ?? DEFAULT_QUALITY
}
return definition
}
export function toNewVariant(values: VariantFormValues): NewVariantRequest {
return { name: values.name, ...toDefinition(values) }
}
+93
View File
@@ -0,0 +1,93 @@
import { formatNumber, t } from '@/i18n'
import type { ImageVariant, VariantFit, VariantFormat } from '@/composables/useImageVariants'
const BYTES_PER_KB = 1024
const BYTES_PER_MB = BYTES_PER_KB * 1024
const KB_PER_MB = 1024
const MB_PER_GB = 1024
const NO_QUALITY = '—'
const SEPARATOR = ' · '
function oneDecimal(value: number) {
return Math.round(value * 10) / 10
}
export function formatVariantSize(bytes: number) {
const kilobytes = Math.round(bytes / BYTES_PER_KB)
if (bytes === 0) {
return t('{0} KB', [0])
}
if (kilobytes < KB_PER_MB) {
return t('{0} KB', [Math.max(1, kilobytes)])
}
const megabytes = bytes / BYTES_PER_MB
if (megabytes < MB_PER_GB) {
return t('{0} MB', [oneDecimal(megabytes)])
}
return t('{0} GB', [oneDecimal(megabytes / MB_PER_GB)])
}
export function formatDimensions(width: number | null, height: number | null) {
if (width !== null && height !== null) {
return `${width}×${height}`
}
if (width !== null) {
return `${width} × ${t('auto')}`
}
return `${t('auto')} × ${height ?? ''}`
}
export function formatLabel(format: VariantFormat) {
const labels: Record<VariantFormat, string> = {
webp: 'WebP',
jpeg: 'JPEG',
png: 'PNG',
original: t('Original'),
}
return labels[format]
}
export function fitLabel(fit: VariantFit) {
const labels: Record<VariantFit, string> = {
cover: t('cover'),
contain: t('contain'),
inside: t('inside'),
}
return labels[fit]
}
function hasQuality(variant: Pick<ImageVariant, 'format' | 'quality'>) {
return variant.format !== 'png' && variant.quality !== null
}
export function qualityCell(variant: Pick<ImageVariant, 'format' | 'quality'>) {
return hasQuality(variant) ? String(variant.quality) : NO_QUALITY
}
export function specLine(variant: ImageVariant) {
const parts = [
formatDimensions(variant.width, variant.height),
fitLabel(variant.fit),
formatLabel(variant.format),
]
if (hasQuality(variant)) {
parts.push(t('q{0}', [variant.quality ?? 0]))
}
return parts.join(SEPARATOR)
}
export function generatedLine(variant: ImageVariant) {
return t('{0} generated · {1}', [
formatNumber(variant.generated_files),
formatVariantSize(variant.cache_size),
])
}
export function variantsSummary(items: readonly ImageVariant[]) {
const files = items.reduce((total, item) => total + item.generated_files, 0)
const bytes = items.reduce((total, item) => total + item.cache_size, 0)
const variants = items.length === 1 ? t('1 variant') : t('{0} variants', [items.length])
const generated =
files === 1 ? t('1 generated file') : t('{0} generated files', [formatNumber(files)])
return [variants, generated, formatVariantSize(bytes)].join(SEPARATOR)
}
+54
View File
@@ -0,0 +1,54 @@
import type { VariantFit } from '@/composables/useImageVariants'
export interface PixelSize {
width: number
height: number
}
export interface OutputRequest {
width: number | null
height: number | null
fit: VariantFit
}
function scaled(size: PixelSize, factor: number): PixelSize {
return { width: Math.round(size.width * factor), height: Math.round(size.height * factor) }
}
function singleDimension(source: PixelSize, width: number | null, height: number | null) {
const factor = width !== null ? width / source.width : (height ?? 0) / source.height
return scaled(source, Math.min(1, factor))
}
function cropToBox(source: PixelSize, box: PixelSize): PixelSize {
const boxRatio = box.width / box.height
if (source.width / source.height > boxRatio) {
return { width: source.height * boxRatio, height: source.height }
}
return { width: source.width, height: source.width / boxRatio }
}
function coverBox(source: PixelSize, box: PixelSize) {
if (box.width >= source.width && box.height >= source.height) {
return source
}
const crop = cropToBox(source, box)
return scaled(crop, Math.min(1, box.width / crop.width))
}
export function outputSize(source: PixelSize, request: OutputRequest): PixelSize | null {
const { width, height, fit } = request
if (width === null && height === null) {
return null
}
if (width === null || height === null) {
return singleDimension(source, width, height)
}
if (fit === 'contain') {
return { width, height }
}
if (fit === 'inside') {
return scaled(source, Math.min(1, width / source.width, height / source.height))
}
return coverBox(source, { width, height })
}