feat: image variants per image

This commit is contained in:
fsylvain committed 2026-10-08 19:57:51 +02:00
1 parent f567866161
commit 951fef18ba
92 files changed
+8695 -413

No files matched your search

+384 -19
View File
@@ -10,7 +10,7 @@ export interface ApiMock {
}
export interface DiffException {
maxPercent: number
maxPercent: number | { desktop: number; mobile: number }
reason: string
approvedBy: string
}
@@ -1837,6 +1837,7 @@ function pickerApi(
'GET /v1/auth/me': mediaSession,
'GET /v1/article/12': { status: 200, body: { ...editorArticle, body } },
'GET /v1/media/': { status: 200, bodyFor: pickerList },
'GET /v1/image-variants': { status: 403 },
...overrides,
}
}
@@ -3308,23 +3309,6 @@ const variantsStates: Record<string, VisualState> = {
await page.mouse.move(0, 0)
},
),
'Entry: sidebar': {
path: '/media',
origin: mediaOrigin,
api: mediaApi({ 'GET /v1/auth/me': variantsSession }),
steps: async (page) => {
if (isMobileViewport(page)) {
await page.locator('[data-slot="toggle"]').first().click()
}
await page
.locator(
'nav[aria-label="Main"]:visible a[href="/settings"] [data-slot="linkTrailing"]',
)
.click()
await page.mouse.move(0, 0)
await page.waitForTimeout(250)
},
},
'Request reference': {
...variantsListState({}, async (page) => {
if (isMobileViewport(page)) {
@@ -3428,6 +3412,383 @@ const variantsStates: Record<string, VisualState> = {
}),
}
const variantBoardSizes: Record<string, number> = {
thumb: 18432,
card: 65536,
hero: 98304,
square: 83968,
og: 145408,
}
const variantBoardOutputs: Record<string, [number, number, string]> = {
thumb: [400, 300, 'webp'],
card: [800, 600, 'webp'],
hero: [1200, 630, 'webp'],
square: [600, 600, 'jpeg'],
og: [1200, 630, 'jpeg'],
}
const variantBoardNames = ['thumb', 'card', 'hero', 'square', 'og']
const variantLibraryTotals: Record<number, [number, number]> = {
1: [4, 266240],
2: [5, 419840],
3: [5, 404480],
5: [3, 245760],
7: [2, 153600],
9: [1, 61440],
10: [3, 194560],
11: [4, 286720],
12: [5, 440320],
}
const variantsMediaPath = '/media'
const variantsPanelId = 1
const variantsSvgId = 4
function variantLibraryItems(ids?: number[]) {
const order = (id: number) => (ids ? ids.indexOf(id) : id)
return mediaSamples
.filter((sample) => !ids || ids.includes(sample.id))
.sort((left, right) => order(left.id) - order(right.id))
.map((sample) => {
const [count, size] = variantLibraryTotals[sample.id] ?? [0, 0]
return {
...mediaItem({ ...sample, duplicate: false }),
variant_count: count,
variant_size: size,
}
})
}
function boardVariantItems(generated: string[], storedName = '1.jpg') {
return variantBoardNames.map((name) => {
const [width, height, format] = variantBoardOutputs[name]!
const isGenerated = generated.includes(name)
return {
name,
width,
height,
fit: 'cover',
format,
output_width: width,
output_height: height,
output_format: format,
generated: isGenerated,
size: isGenerated ? variantBoardSizes[name]! : null,
url: `/static/uploadedImages/${storedName}?v=${name}`,
}
})
}
function variantsMediaApi(
ids?: number[],
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': variantsSession,
'GET /v1/media/': { status: 200, body: mediaList(variantLibraryItems(ids)) },
'GET /v1/media/1/usage': { status: 200, body: mediaNorthWallUsage },
...overrides,
}
}
function variantsMediaState(
overrides: Record<string, ApiMock> = {},
steps?: VisualState['steps'],
path = variantsMediaPath,
ids?: number[],
): VisualState {
return { path, api: variantsMediaApi(ids, overrides), steps }
}
async function openSortMenuOf(page: Page) {
await page.getByRole('button', { name: 'Sort', exact: true }).click()
await page.getByRole('menu').waitFor()
}
async function openImagePanel(page: Page, id = variantsPanelId) {
await page.locator(`[data-media-card][data-media-id="${id}"]`).click()
await page.getByRole('dialog').waitFor()
}
async function openVariantsTabOf(page: Page, id = variantsPanelId) {
await openImagePanel(page, id)
await page.getByRole('tab', { name: 'Variants', exact: true }).click()
await page.getByTestId('variants-tab').locator('[data-testid="variants-original"]').waitFor()
await page.mouse.move(0, 0)
}
function variantRow(page: Page, name: string) {
return page.locator(`[data-testid="variant-row"][data-variant="${name}"]`)
}
function variantsTabApi(
generated: string[],
overrides: Record<string, ApiMock> = {},
id = variantsPanelId,
) {
return variantsMediaApi(undefined, {
[`GET /v1/media/${id}/variants`]: {
status: 200,
body: { unsupported: null, items: boardVariantItems(generated) },
},
...overrides,
})
}
function variantsTabState(
generated: string[],
overrides: Record<string, ApiMock> = {},
steps?: VisualState['steps'],
): VisualState {
return {
path: variantsMediaPath,
api: variantsTabApi(generated, overrides),
steps: async (page) => {
await openVariantsTabOf(page)
await steps?.(page)
},
}
}
const variantsPartlyGenerated = ['thumb', 'card', 'hero', 'square']
const variantsFailedGeneration: ApiMock = {
status: 200,
body: {
generated: 2,
failed: 2,
failures: [
{ name: 'card', cause: 'unsupported_format' },
{ name: 'hero', cause: 'too_large' },
],
items: boardVariantItems(['thumb', 'square', 'og']),
},
}
const variantsPickerPath = '/article/12/edit'
function variantsBoardArticleBody(src: string | null) {
const image = src ? `<img src="${src}" alt="" data-image-id="1">` : ''
return `<p>${pickerIntro}</p>${image}<p>${pickerOutro}</p>`
}
function variantsPickerApi(catalog: unknown[]) {
return pickerApi(
{
'GET /v1/auth/me': variantsSession,
'GET /v1/media/': {
status: 200,
body: {
items: variantLibraryItems([1, 2, 3, 5]),
next_cursor: null,
counts: mediaFullCounts,
},
},
'GET /v1/image-variants': { status: 200, body: { items: catalog } },
},
variantsBoardArticleBody(null),
)
}
function variantsPickerState(
catalog: unknown[],
steps?: (page: Page) => Promise<void>,
): VisualState {
return {
path: variantsPickerPath,
api: variantsPickerApi(catalog),
steps: async (page) => {
await stubEditorImages(page)
await steps?.(page)
},
}
}
async function chooseBoardVariant(page: Page, label: string) {
await page.getByTestId('variant-select').click()
await page.getByRole('option', { name: label }).first().click()
await page.mouse.move(0, 0)
}
function variantsBlockState(catalog: unknown[]): VisualState {
return {
path: variantsPickerPath,
api: pickerApi(
{
'GET /v1/auth/me': variantsSession,
'GET /v1/image-variants': { status: 200, body: { items: catalog } },
},
variantsBoardArticleBody('/media/north-wall.jpg?v=hero'),
),
steps: async (page) => {
await page.getByTestId('article-editor-variant-warning').waitFor()
await page.mouse.move(0, 0)
},
}
}
const variantsWithoutHero = variantsBoardList.filter((entry) => entry.name !== 'hero')
const variantsHeroDisabled = variantsBoardList.map((entry) =>
entry.name === 'hero' ? { ...entry, enabled: false } : entry,
)
const variantsMediaStates: Record<string, VisualState> = {
'Entry: sidebar': {
path: '/media',
origin: mediaOrigin,
api: mediaApi({ 'GET /v1/auth/me': variantsSession }),
steps: async (page) => {
if (isMobileViewport(page)) {
await page.locator('[data-slot="toggle"]').first().click()
}
await page
.locator(
'nav[aria-label="Main"]:visible a[href="/settings"] [data-slot="linkTrailing"]',
)
.click()
await page.mouse.move(0, 0)
await page.waitForTimeout(250)
},
},
'3a · Grid with badges': variantsMediaState(),
'3b · Sort menu': variantsMediaState(
{},
async (page) => {
await openSortMenuOf(page)
await page.mouse.move(0, 0)
},
'/media?sort=total_size',
),
'3c · Filter: With variants, sorted by total size': variantsMediaState(
{},
undefined,
'/media?variants=with&sort=total_size',
[1, 7, 9, 12, 5, 11, 2, 3, 10],
),
'3d · Filter: Without variants': variantsMediaState(
{},
undefined,
'/media?variants=without',
[4, 6, 8],
),
'3e · Filter: nothing to show': variantsMediaState({}, undefined, '/media?variants=with', []),
'3f · Delete confirm': variantsMediaState({}, async (page) => {
await openImagePanel(page)
await page.getByRole('dialog').getByRole('button', { name: 'Delete', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
await page.mouse.move(0, 0)
}),
'4a · Partly generated': variantsTabState(variantsPartlyGenerated),
'4b · Nothing generated yet': variantsTabState([]),
'4c · Generating': variantsTabState(
['thumb'],
{
'POST /v1/media/1/variants/card': { status: 200, hang: true },
'POST /v1/media/1/variants/hero': { status: 200, hang: true },
},
async (page) => {
await variantRow(page, 'card').getByRole('button', { name: 'Generate now' }).click()
await variantRow(page, 'hero').getByRole('button', { name: 'Generate now' }).click()
await page.mouse.move(0, 0)
},
),
'4d · Generation failed': variantsTabState(
['thumb'],
{ 'POST /v1/media/1/variants': variantsFailedGeneration },
async (page) => {
await page.getByRole('button', { name: 'Generate all', exact: true }).click()
await variantRow(page, 'card').getByText('Failed').waitFor()
await page.mouse.move(0, 0)
},
),
'4e · No variants defined': {
path: variantsMediaPath,
api: variantsMediaApi(undefined, {
'GET /v1/media/1/variants': { status: 200, body: { unsupported: null, items: [] } },
}),
steps: async (page) => {
await openVariantsTabOf(page)
},
},
'4f · Source is an SVG': {
path: variantsMediaPath,
api: variantsMediaApi(undefined, {
'GET /v1/media/4/variants': { status: 200, body: { unsupported: 'svg', items: [] } },
'GET /v1/media/4/usage': { status: 200, body: [] },
}),
steps: async (page) => {
await openVariantsTabOf(page, variantsSvgId)
},
},
'4h · Preview dialog': variantsTabState(variantsPartlyGenerated, {}, async (page) => {
await variantRow(page, 'thumb').getByRole('button', { name: 'Preview' }).click()
await page.getByRole('dialog').filter({ hasText: 'Preview: thumb' }).waitFor()
await page.mouse.move(0, 0)
}),
}
const variantsEditorStates: Record<string, VisualState> = {
'6a · Variant select': variantsPickerState(variantsBoardList, async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'north-wall.jpg')
await chooseBoardVariant(page, 'Hero')
}),
'6b · Select open': variantsPickerState(variantsBoardList, async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'north-wall.jpg')
await page.getByTestId('variant-select').click()
await page.getByRole('listbox').waitFor()
await page.mouse.move(0, 0)
}),
'6c · No variants defined': variantsPickerState([], async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'north-wall.jpg')
await page.getByTestId('variant-help').waitFor()
await page.mouse.move(0, 0)
}),
'6d · Variant deleted': variantsBlockState(variantsWithoutHero),
'6e · Variant disabled': variantsBlockState(variantsHeroDisabled),
}
const boardPredatesPages = 'board predates current Media page and editor'
const variantExceptionCaps: Record<string, { desktop: number; mobile: number }> = {
'3a': { desktop: 28, mobile: 42 },
'3b': { desktop: 28, mobile: 42 },
'3c': { desktop: 28, mobile: 42 },
'3d': { desktop: 10, mobile: 39 },
'3e': { desktop: 10, mobile: 39 },
'3f': { desktop: 78, mobile: 54 },
'4a': { desktop: 15, mobile: 10 },
'4b': { desktop: 15, mobile: 10 },
'4c': { desktop: 15, mobile: 10 },
'4d': { desktop: 15, mobile: 10 },
'4e': { desktop: 15, mobile: 10 },
'4f': { desktop: 15, mobile: 10 },
'6a': { desktop: 29, mobile: 52 },
'6b': { desktop: 29, mobile: 52 },
'6c': { desktop: 29, mobile: 52 },
'6d': { desktop: 29, mobile: 52 },
'6e': { desktop: 29, mobile: 52 },
}
function withVariantExceptions(states: Record<string, VisualState>) {
return Object.fromEntries(
Object.entries(states).map(([name, state]) => {
const cap = variantExceptionCaps[name.slice(0, 2)]
return [
name,
cap
? {
...state,
exception: {
maxPercent: cap,
reason: boardPredatesPages,
approvedBy: 'user',
},
}
: state,
]
}),
)
}
export const visualStates: Record<string, Record<string, VisualState>> = {
activity: activityStates,
profile: {
@@ -4566,7 +4927,11 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: submitAcceptInvite('lea', 'limewash2026'),
},
},
'image-variants': variantsStates,
'image-variants': {
...variantsStates,
...withVariantExceptions(variantsMediaStates),
...withVariantExceptions(variantsEditorStates),
},
media: { ...mediaStates, ...mediaPurgeStates },
'media-picker': pickerStates,
}