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