feat: image variants per image
This commit is contained in:
1 parent
f567866161
commit
951fef18ba
92 files changed
+8695
-413
No files matched your search
@@ -230,6 +230,8 @@ describe('The catalogue is complete in both languages', () => {
|
||||
'media.delete',
|
||||
'media.merge',
|
||||
'media.purge',
|
||||
'media.variant_generate_all',
|
||||
'media.variant_purge_image',
|
||||
'system.media_purge',
|
||||
'user.register',
|
||||
'user.verify',
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { Editor, EditorContent } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { ArticleImage, findArticleImagePos } from '../articleImage'
|
||||
import { bumpMediaRevision } from '../mediaRevision'
|
||||
|
||||
function mountHarness(onRequestRemove = vi.fn()) {
|
||||
setActivePinia(createPinia())
|
||||
let editor: Editor
|
||||
const Harness = defineComponent({
|
||||
components: { EditorContent },
|
||||
|
||||
@@ -2,6 +2,8 @@ import { formatNumber, t } from '@/i18n'
|
||||
import type { MessageKey } from '@/i18n/messages'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import { sizeLabel } from '@/lib/activityRetention'
|
||||
import { formatSize } from '@/lib/mediaFormat'
|
||||
import { causeText } from '@/lib/variants/variantCauses'
|
||||
|
||||
export interface SentenceSegment {
|
||||
text: string
|
||||
@@ -56,6 +58,14 @@ const CATALOGUE: Record<string, Template> = {
|
||||
success: 'Ran the scheduled duplicate purge: {0}',
|
||||
failed: 'Failed to run the scheduled duplicate purge',
|
||||
},
|
||||
'media.variant_generate_all': {
|
||||
success: 'Generated all variants of {0}',
|
||||
failed: 'Failed to generate the variants of {0}',
|
||||
},
|
||||
'media.variant_purge_image': {
|
||||
success: 'Purged the variants of {0}',
|
||||
failed: 'Failed to purge the variants of {0}',
|
||||
},
|
||||
'user.register': {
|
||||
success: 'Registered the account {0}',
|
||||
failed: 'Failed to register the account {0}',
|
||||
@@ -224,6 +234,44 @@ export function composeSentence(item: ActivityItem): SentenceSegment[] {
|
||||
return segments.filter((segment) => segment.text !== '')
|
||||
}
|
||||
|
||||
function causeList(value: unknown) {
|
||||
const causes = Array.isArray(value) ? value.filter((cause) => typeof cause === 'string') : []
|
||||
return [...new Set(causes)].map(causeText).join(', ')
|
||||
}
|
||||
|
||||
function generateAllDetail(params: Record<string, unknown>) {
|
||||
const generated = count(params.generated)
|
||||
const failed = count(params.failed)
|
||||
if (failed === 0) {
|
||||
return generated === 1
|
||||
? t('1 variant generated')
|
||||
: t('{0} variants generated', [formatNumber(generated)])
|
||||
}
|
||||
return t('{0} generated · {1} failed ({2})', [
|
||||
formatNumber(generated),
|
||||
formatNumber(failed),
|
||||
causeList(params.causes),
|
||||
])
|
||||
}
|
||||
|
||||
function purgeImageDetail(params: Record<string, unknown>) {
|
||||
const files = count(params.purged_files)
|
||||
const size = formatSize(count(params.purged_bytes))
|
||||
return files === 1
|
||||
? t('Removed 1 file ({0})', [size])
|
||||
: t('Removed {0} files ({1})', [formatNumber(files), size])
|
||||
}
|
||||
|
||||
export function detailLine(item: ActivityItem): string | null {
|
||||
if (item.action === 'media.variant_generate_all') {
|
||||
return generateAllDetail(item.params)
|
||||
}
|
||||
if (item.action === 'media.variant_purge_image') {
|
||||
return purgeImageDetail(item.params)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function sentenceText(item: ActivityItem) {
|
||||
return composeSentence(item)
|
||||
.map((segment) => segment.text)
|
||||
|
||||
@@ -36,6 +36,24 @@ export function formatSize(bytes: number) {
|
||||
])
|
||||
}
|
||||
|
||||
export function totalMeta(item: { size: number; variant_size: number; variant_count: number }) {
|
||||
const total = formatSize(item.size + item.variant_size)
|
||||
if (item.variant_count > 0) {
|
||||
return t('{0} total · {1} + {2}', [
|
||||
total,
|
||||
formatSize(item.size),
|
||||
formatSize(item.variant_size),
|
||||
])
|
||||
}
|
||||
return t('{0} total', [total])
|
||||
}
|
||||
|
||||
export function removalNote(count: number, bytes: number) {
|
||||
return count === 1
|
||||
? t('Also removes 1 generated variant ({0})', [formatSize(bytes)])
|
||||
: t('Also removes {0} generated variants ({1})', [count, formatSize(bytes)])
|
||||
}
|
||||
|
||||
export function formatDimensions(width: number | null, height: number | null) {
|
||||
if (width === null || height === null) {
|
||||
return t(VECTOR_DIMENSIONS)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
|
||||
export type MediaFilter = 'all' | 'unused' | 'duplicates'
|
||||
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest'
|
||||
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest' | 'total_size'
|
||||
export type MediaVariantsFilter = 'all' | 'with' | 'without'
|
||||
export type MediaView = 'grid' | 'list'
|
||||
|
||||
export interface MediaControls {
|
||||
q: string
|
||||
filter: MediaFilter
|
||||
sort: MediaSort
|
||||
variants: MediaVariantsFilter
|
||||
view: MediaView
|
||||
}
|
||||
|
||||
@@ -17,11 +19,13 @@ export const DEFAULT_CONTROLS: MediaControls = {
|
||||
q: '',
|
||||
filter: 'all',
|
||||
sort: 'newest',
|
||||
variants: 'all',
|
||||
view: 'grid',
|
||||
}
|
||||
|
||||
const FILTERS: MediaFilter[] = ['all', 'unused', 'duplicates']
|
||||
const SORTS: MediaSort[] = ['newest', 'oldest', 'name', 'largest']
|
||||
const SORTS: MediaSort[] = ['newest', 'oldest', 'name', 'largest', 'total_size']
|
||||
const VARIANTS_FILTERS: MediaVariantsFilter[] = ['all', 'with', 'without']
|
||||
const VIEWS: MediaView[] = ['grid', 'list']
|
||||
|
||||
function firstValue(value: LocationQuery[string] | undefined) {
|
||||
@@ -37,6 +41,7 @@ export function parseMediaControls(query: LocationQuery): MediaControls {
|
||||
q: firstValue(query.q).trim(),
|
||||
filter: pick(FILTERS, firstValue(query.filter), DEFAULT_CONTROLS.filter),
|
||||
sort: pick(SORTS, firstValue(query.sort), DEFAULT_CONTROLS.sort),
|
||||
variants: pick(VARIANTS_FILTERS, firstValue(query.variants), DEFAULT_CONTROLS.variants),
|
||||
view: pick(VIEWS, firstValue(query.view), DEFAULT_CONTROLS.view),
|
||||
}
|
||||
}
|
||||
@@ -52,6 +57,9 @@ export function toRouteQuery(controls: MediaControls) {
|
||||
if (controls.sort !== DEFAULT_CONTROLS.sort) {
|
||||
query.sort = controls.sort
|
||||
}
|
||||
if (controls.variants !== DEFAULT_CONTROLS.variants) {
|
||||
query.variants = controls.variants
|
||||
}
|
||||
if (controls.view !== DEFAULT_CONTROLS.view) {
|
||||
query.view = controls.view
|
||||
}
|
||||
@@ -63,6 +71,7 @@ export function toRequestQuery(controls: MediaControls) {
|
||||
...(controls.q ? [`q=${encodeURIComponent(controls.q)}`] : []),
|
||||
`filter=${controls.filter}`,
|
||||
`sort=${controls.sort}`,
|
||||
...(controls.variants === 'all' ? [] : [`variants=${controls.variants}`]),
|
||||
`limit=${MEDIA_PAGE_LIMIT}`,
|
||||
]
|
||||
return parts.join('&')
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
export const UNDERLINE_TAB_TRIGGER =
|
||||
'h-9 px-0 py-0 data-[state=inactive]:text-toned in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:bottom-0 in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:h-0.5 max-md:h-11 max-md:text-[0.9375rem]'
|
||||
|
||||
export const PANEL_TAB_TRIGGER = UNDERLINE_TAB_TRIGGER.replace('h-9', 'h-10')
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { withMediaRevision } from '@/lib/mediaRevision'
|
||||
import { bodyHasVariantImage, variantOfSrc, withVariant } from '../blockVariant'
|
||||
|
||||
describe('Reading the variant of an image src', () => {
|
||||
it.each([
|
||||
['/static/uploadedImages/1.jpg?v=hero', 'hero'],
|
||||
['/static/uploadedImages/1.jpg?v=hero&rev=3', 'hero'],
|
||||
['/static/uploadedImages/1.jpg?rev=3&v=thumb#top', 'thumb'],
|
||||
['/static/uploadedImages/1.jpg', null],
|
||||
['/static/uploadedImages/1.jpg?rev=3', null],
|
||||
['/static/uploadedImages/1.jpg?v=', null],
|
||||
[undefined, null],
|
||||
])('%s reads %s', (src, expected) => {
|
||||
expect(variantOfSrc(src)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rewriting the variant of an image src', () => {
|
||||
it.each([
|
||||
['/static/uploadedImages/1.jpg?v=hero', 'thumb', '/static/uploadedImages/1.jpg?v=thumb'],
|
||||
['/static/uploadedImages/1.jpg?v=hero', '', '/static/uploadedImages/1.jpg'],
|
||||
['/static/uploadedImages/1.jpg', 'hero', '/static/uploadedImages/1.jpg?v=hero'],
|
||||
[
|
||||
'/static/uploadedImages/1.jpg?rev=3&v=hero#a',
|
||||
'og',
|
||||
'/static/uploadedImages/1.jpg?rev=3&v=og#a',
|
||||
],
|
||||
])('%s with %s becomes %s', (src, name, expected) => {
|
||||
expect(withVariant(src, name)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Detecting variant images in a body', () => {
|
||||
it('finds an img whose src carries ?v=', () => {
|
||||
expect(bodyHasVariantImage('<p>x</p><img src="/static/uploadedImages/1.jpg?v=hero">')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores plain images and text', () => {
|
||||
expect(bodyHasVariantImage('<p>?v=hero</p><img src="/static/uploadedImages/1.jpg">')).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The cache-busting parameter coexists with the variant', () => {
|
||||
it('is appended after ?v=', () => {
|
||||
expect(withMediaRevision('/static/uploadedImages/1.jpg?v=hero', 3)).toBe(
|
||||
'/static/uploadedImages/1.jpg?v=hero&rev=3',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { initializeLocale, setLocale } from '@/i18n'
|
||||
import type { ImageVariant } from '@/composables/useImageVariants'
|
||||
import { buildOptions, insertedUrl, originalOption, variantOption } from '../variantOptions'
|
||||
|
||||
const NORTH_WALL = {
|
||||
url: '/static/uploadedImages/1.jpg',
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
content_type: 'image/jpeg',
|
||||
}
|
||||
|
||||
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: 0,
|
||||
cache_size: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
})
|
||||
|
||||
describe('Option labels', () => {
|
||||
it('reads the original from the image', () => {
|
||||
expect(originalOption(NORTH_WALL).label).toBe('Original · 4032×3024 · JPEG')
|
||||
expect(originalOption({ ...NORTH_WALL, content_type: 'image/webp' }).label).toBe(
|
||||
'Original · 4032×3024 · WEBP',
|
||||
)
|
||||
expect(
|
||||
originalOption({
|
||||
...NORTH_WALL,
|
||||
width: null,
|
||||
height: null,
|
||||
content_type: 'image/svg+xml',
|
||||
}).label,
|
||||
).toBe('Original · Vector · SVG')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[variant({}), 'Hero · 1200×630 · WebP'],
|
||||
[
|
||||
variant({ name: 'card', width: 800, height: null, format: 'webp' }),
|
||||
'Card · 800 × auto · WebP',
|
||||
],
|
||||
[
|
||||
variant({ name: 'tall', width: null, height: 2400, format: 'png' }),
|
||||
'Tall · auto × 2400 · PNG',
|
||||
],
|
||||
[variant({ name: 'keep', format: 'original' }), 'Keep · 1200×630 · Original'],
|
||||
])('reads a variant', (entry, label) => {
|
||||
expect(variantOption(entry).label).toBe(label)
|
||||
})
|
||||
|
||||
it('offers only Original for a vector image', () => {
|
||||
const options = buildOptions(
|
||||
{ ...NORTH_WALL, width: null, height: null, content_type: 'image/svg+xml' },
|
||||
[variant({})],
|
||||
)
|
||||
|
||||
expect(options.map((option) => option.label)).toEqual(['Original · Vector · SVG'])
|
||||
})
|
||||
|
||||
it('translates the original label', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(originalOption(NORTH_WALL).label).toBe('Original · 4032×3024 · JPEG')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The inserted url', () => {
|
||||
it('is the image url for Original and carries ?v= for a variant', () => {
|
||||
expect(insertedUrl(NORTH_WALL, '')).toBe('/static/uploadedImages/1.jpg')
|
||||
expect(insertedUrl(NORTH_WALL, 'hero')).toBe('/static/uploadedImages/1.jpg?v=hero')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
const VARIANT_PARAMETER = 'v'
|
||||
const HASH = '#'
|
||||
const QUERY = '?'
|
||||
|
||||
function splitSrc(src: string) {
|
||||
const hashAt = src.indexOf(HASH)
|
||||
const withoutHash = hashAt === -1 ? src : src.slice(0, hashAt)
|
||||
const hash = hashAt === -1 ? '' : src.slice(hashAt)
|
||||
const queryAt = withoutHash.indexOf(QUERY)
|
||||
if (queryAt === -1) {
|
||||
return { base: withoutHash, query: '', hash }
|
||||
}
|
||||
return { base: withoutHash.slice(0, queryAt), query: withoutHash.slice(queryAt + 1), hash }
|
||||
}
|
||||
|
||||
export function variantOfSrc(src: string | undefined | null) {
|
||||
if (!src) {
|
||||
return null
|
||||
}
|
||||
const name = new URLSearchParams(splitSrc(src).query).get(VARIANT_PARAMETER)
|
||||
return name === null || name === '' ? null : name
|
||||
}
|
||||
|
||||
export function withVariant(src: string, name: string) {
|
||||
const { base, query, hash } = splitSrc(src)
|
||||
const params = new URLSearchParams(query)
|
||||
params.delete(VARIANT_PARAMETER)
|
||||
const kept = params.toString()
|
||||
const parts = [kept, name === '' ? '' : `${VARIANT_PARAMETER}=${encodeURIComponent(name)}`]
|
||||
const next = parts.filter((part) => part !== '').join('&')
|
||||
return `${base}${next === '' ? '' : QUERY + next}${hash}`
|
||||
}
|
||||
|
||||
export function bodyHasVariantImage(html: string) {
|
||||
const document = new DOMParser().parseFromString(html, 'text/html')
|
||||
return Array.from(document.querySelectorAll('img')).some(
|
||||
(image) => variantOfSrc(image.getAttribute('src')) !== null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { VariantCause } from '@/composables/useMediaVariants'
|
||||
|
||||
const PAYLOAD_TOO_LARGE = 413
|
||||
const UNSUPPORTED_MEDIA_TYPE = 415
|
||||
const UNPROCESSABLE = 422
|
||||
|
||||
export function causeText(cause: string) {
|
||||
if (cause === 'too_large') {
|
||||
return t('Image too large')
|
||||
}
|
||||
if (cause === 'unsupported_format') {
|
||||
return t('Unsupported source format')
|
||||
}
|
||||
return t('Generation failed')
|
||||
}
|
||||
|
||||
export function causeOfStatus(status: number): VariantCause | null {
|
||||
if (status === PAYLOAD_TOO_LARGE) {
|
||||
return 'too_large'
|
||||
}
|
||||
if (status === UNSUPPORTED_MEDIA_TYPE || status === UNPROCESSABLE) {
|
||||
return 'unsupported_format'
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { ImageVariant, VariantFormat } from '@/composables/useImageVariants'
|
||||
import { formatType } from '@/lib/mediaFormat'
|
||||
import { formatDimensions, formatLabel } from '@/lib/variants/variantFormat'
|
||||
|
||||
const SEPARATOR = ' · '
|
||||
const VECTOR = 'Vector'
|
||||
const SVG_TYPE = 'image/svg+xml'
|
||||
const ORIGINAL_VALUE = ''
|
||||
|
||||
export interface VariantOption {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface OptionSource {
|
||||
url: string
|
||||
width: number | null
|
||||
height: number | null
|
||||
content_type: string
|
||||
}
|
||||
|
||||
export const ORIGINAL_OPTION_VALUE = ORIGINAL_VALUE
|
||||
|
||||
export function isVector(source: OptionSource) {
|
||||
return source.content_type === SVG_TYPE
|
||||
}
|
||||
|
||||
function sourceSize(source: OptionSource) {
|
||||
if (source.width === null || source.height === null) {
|
||||
return t(VECTOR)
|
||||
}
|
||||
return `${source.width}×${source.height}`
|
||||
}
|
||||
|
||||
export function capitalised(name: string) {
|
||||
return name.charAt(0).toUpperCase() + name.slice(1)
|
||||
}
|
||||
|
||||
export function originalOption(source: OptionSource): VariantOption {
|
||||
return {
|
||||
value: ORIGINAL_VALUE,
|
||||
label: [t('Original'), sourceSize(source), formatType(source.content_type)].join(SEPARATOR),
|
||||
}
|
||||
}
|
||||
|
||||
export function bareOriginalOption(): VariantOption {
|
||||
return { value: ORIGINAL_VALUE, label: t('Original') }
|
||||
}
|
||||
|
||||
export function variantOption(variant: ImageVariant): VariantOption {
|
||||
return {
|
||||
value: variant.name,
|
||||
label: [
|
||||
capitalised(variant.name),
|
||||
formatDimensions(variant.width, variant.height),
|
||||
formatLabel(variant.format as VariantFormat),
|
||||
].join(SEPARATOR),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOptions(source: OptionSource, variants: ImageVariant[]): VariantOption[] {
|
||||
const offered = isVector(source) ? [] : variants.map(variantOption)
|
||||
return [originalOption(source), ...offered]
|
||||
}
|
||||
|
||||
export function insertedUrl(source: OptionSource, variantName: string) {
|
||||
return variantName === ORIGINAL_VALUE
|
||||
? source.url
|
||||
: `${source.url}?v=${encodeURIComponent(variantName)}`
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const MAX_MAGNITUDE = 99
|
||||
const PERCENT = 100
|
||||
|
||||
export function savedPercent(originalBytes: number, variantBytes: number) {
|
||||
if (originalBytes <= 0) {
|
||||
return 0
|
||||
}
|
||||
const change = ((variantBytes - originalBytes) / originalBytes) * PERCENT
|
||||
const rounded = Math.sign(change) * Math.round(Math.abs(change))
|
||||
if (variantBytes > 0 && rounded < -MAX_MAGNITUDE) {
|
||||
return -MAX_MAGNITUDE
|
||||
}
|
||||
return rounded
|
||||
}
|
||||
|
||||
export function signedPercent(originalBytes: number, variantBytes: number) {
|
||||
const percent = savedPercent(originalBytes, variantBytes)
|
||||
return `${percent > 0 ? '+' : ''}${percent}%`
|
||||
}
|
||||
|
||||
export function savesPercent(originalBytes: number, variantBytes: number) {
|
||||
return Math.max(0, -savedPercent(originalBytes, variantBytes))
|
||||
}
|
||||
Reference in new issue
Block a user