feat: media picker and image removal

This commit is contained in:
fsylvain committed 2026-10-01 05:04:58 +02:00
1 parent 008b0dfbb9
commit b3793d5dbb
54 files changed
+3769 -1412

No files matched your search

+273 -5
View File
@@ -186,10 +186,17 @@ const editorArticle = {
updated_at: editorNow,
}
const emptyLibrary = {
items: [],
next_cursor: null,
counts: { all: 0, unused: 0, duplicates: 0 },
}
function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/12': { status: 200, body: editorArticle },
'GET /v1/media/': { status: 200, body: emptyLibrary },
...overrides,
}
}
@@ -384,7 +391,7 @@ async function freezeUploadProgress(page: Page, loaded: number, total: number) {
const originalSend = proto.send as (...args: unknown[]) => void
proto.send = function (this: XMLHttpRequest, ...args: unknown[]) {
const url = (this as unknown as { __imageUrl?: string }).__imageUrl ?? ''
if (url.includes('/image')) {
if (url.includes('/v1/media')) {
setTimeout(() => {
const event = new Event('progress')
Object.assign(event, { loaded, total, lengthComputable: true })
@@ -1583,6 +1590,253 @@ const mediaStates: Record<string, VisualState> = {
Offline: mediaState(mediaApi(), goOffline),
}
const pickerIntro =
'A bathroom is priced by what has to happen behind the walls before anything gets tiled.'
const pickerOutro = 'We don’t add a margin on materials you choose yourself.'
const invisibleImage = '/static/uploadedImages/blank.svg'
const invisibleImageSvg = '<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"/>'
const boardImageSvgFor = (width: number, height: number) =>
[
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 672 378" preserveAspectRatio="xMidYMid slice">`,
'<style>.tone{fill:#e8dfd1}.ink{color:#7d6d56}@media (prefers-color-scheme:dark){.tone{fill:#d4c5b0}}</style>',
'<defs><pattern id="hatch" width="12" height="12" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">',
'<rect width="1" height="12" fill="rgb(128 128 128 / 0.12)"/></pattern></defs>',
'<rect class="tone" width="672" height="378"/>',
'<rect fill="url(#hatch)" width="672" height="378"/>',
'<g class="ink" opacity="0.55" transform="translate(316 160) scale(1.6667)" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">',
'<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></g>',
'</svg>',
].join('')
const boardImageMaxWidth = 672
const boardImageSideSpace = 32
function boardImageFor(page: Page) {
const viewport = page.viewportSize()
const width = Math.min(
boardImageMaxWidth,
(viewport?.width ?? boardImageMaxWidth) - boardImageSideSpace,
)
return boardImageSvgFor(width, Math.round((width * 9) / 16))
}
const boardImage = '/static/uploadedImages/board-image.svg'
function pickerBody(image: string | null, ids: number[] = []) {
const nodes = ids.map(
(id) => `<img src="${image ?? invisibleImage}" alt="" data-image-id="${id}">`,
)
return `<p>${pickerIntro}</p>${nodes.join('')}<p>${pickerOutro}</p>`
}
const pickerCounts = { all: 48, unused: 2, duplicates: 2 }
const pickerFirstPageCursor = 'c24'
const pickerSearchTerm = 'wall'
function pickerList(url: URL) {
if (url.searchParams.has('hash')) {
return mediaList(mediaItems([1]), pickerCounts)
}
if (url.searchParams.has('q')) {
return { items: [], next_cursor: null, counts: pickerCounts }
}
return { items: mediaItems(), next_cursor: pickerFirstPageCursor, counts: pickerCounts }
}
function pickerApi(
overrides: Record<string, ApiMock> = {},
body = pickerBody(null, [1, 4]),
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': mediaSession,
'GET /v1/article/12': { status: 200, body: { ...editorArticle, body } },
'GET /v1/media/': { status: 200, bodyFor: pickerList },
...overrides,
}
}
function pickerState(
steps?: (page: Page) => Promise<void>,
api: Record<string, ApiMock> = pickerApi(),
): VisualState {
return {
path: '/article/12/edit',
api,
steps: async (page) => {
await stubEditorImages(page)
await steps?.(page)
},
}
}
async function openPickerTab(page: Page, name: 'Upload' | 'Library') {
await openImageUploader(page)
await page.getByRole('tab', { name, exact: true }).click()
}
async function waitForPickerTiles(page: Page) {
await page.locator('[data-picker-tile]').first().waitFor()
}
async function selectPickerTile(page: Page, filename: string) {
await waitForPickerTiles(page)
await page.locator(`[data-picker-tile][aria-label^="${filename},"]`).click()
await page.getByTestId('picker-panel').waitFor()
}
async function insertFromLibrary(page: Page, filename: string) {
await openImageUploader(page)
await selectPickerTile(page, filename)
await page.getByRole('button', { name: 'Insert image', exact: true }).click()
await page.getByText('Save to keep it in the article.').first().waitFor()
}
async function selectEditorImage(page: Page) {
await page.locator('[data-testid="article-editor-image"] img').first().click()
await page.getByRole('button', { name: 'Remove image', exact: true }).waitFor()
}
async function stubEditorImages(page: Page) {
await page.route('**/static/uploadedImages/blank.svg', (route) =>
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: invisibleImageSvg }),
)
await page.route('**/static/uploadedImages/board-image.svg', (route) =>
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: boardImageFor(page) }),
)
await page.reload()
await page.waitForFunction(
() => (document.querySelector('#app')?.textContent ?? '').trim().length > 0,
)
}
async function stubBoardImage(page: Page) {
await page.route('**/media/north-wall.jpg', (route) =>
route.fulfill({ status: 200, contentType: 'image/svg+xml', body: boardImageFor(page) }),
)
}
async function browseLibraryThenClose(page: Page) {
await openImageUploader(page)
await waitForPickerTiles(page)
await page.keyboard.press('Escape')
await page.getByRole('dialog').waitFor({ state: 'hidden' })
}
async function requestRemoveFromEditor(page: Page) {
await browseLibraryThenClose(page)
await selectEditorImage(page)
await page.getByRole('button', { name: 'Remove image', exact: true }).click()
await page.getByRole('alertdialog').waitFor()
}
const simplifiedEditorReason =
'The board draws a simplified editor (centred 680 px column, no toolbar, no details panel, fixed image placeholder) while the real editor is left-aligned with a toolbar, a taller header and a details panel, so the image block and page chrome sit elsewhere'
const simplifiedEditorMaxPercent = 40
function withSimplifiedEditorException(state: VisualState): VisualState {
return {
...state,
exception: {
maxPercent: simplifiedEditorMaxPercent,
reason: simplifiedEditorReason,
approvedBy: 'user',
},
}
}
const pickerStates: Record<string, VisualState> = {
'Upload tab': pickerState(async (page) => {
await openPickerTab(page, 'Upload')
}),
'Upload tab: already in your library': {
...pickerState(async (page) => {
await openPickerTab(page, 'Upload')
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
await page.getByText('Already in your library').waitFor()
}),
exception: {
maxPercent: 20,
reason: 'The board buttons collapse to about 22 px on mobile (flex:1 in a column) while the feature requires 44 px full-width stacked buttons, which makes the modal 31 px taller',
approvedBy: 'user',
},
},
'Library: loading': pickerState(
async (page) => {
await openImageUploader(page)
await page.getByTestId('picker-skeleton-tile').first().waitFor()
},
pickerApi({ 'GET /v1/media/': { status: 200, hang: true } }),
),
'Library: nothing selected': pickerState(async (page) => {
await openImageUploader(page)
await waitForPickerTiles(page)
}),
'Library: selected': pickerState(async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'kitchen-after.webp')
}),
'Library: alt text entered': pickerState(async (page) => {
await openImageUploader(page)
await selectPickerTile(page, 'kitchen-after.webp')
await page
.getByPlaceholder('e.g. North wall of the kitchen before plastering')
.fill('A kitchen with new oak worktops and white tiles')
}),
'Library: loading more': pickerState(async (page) => {
await openImageUploader(page)
await waitForPickerTiles(page)
await page.route(/\/v1\/media\/\?.*cursor=/, () => undefined)
await page.getByRole('button', { name: 'Load more', exact: true }).click()
await page.getByTestId('picker-skeleton-tile').first().waitFor()
}),
'Library: empty': pickerState(
async (page) => {
await openPickerTab(page, 'Library')
await page.getByText('Your library is empty').waitFor()
},
pickerApi({ 'GET /v1/media/': { status: 200, body: emptyLibrary } }),
),
'Library: no results': pickerState(async (page) => {
await openImageUploader(page)
await waitForPickerTiles(page)
await page.getByLabel('Search by filename').fill(pickerSearchTerm)
await page.getByText(`No images match "${pickerSearchTerm}"`).waitFor()
}),
'Library: failed to load': pickerState(
async (page) => {
await openImageUploader(page)
await page.getByText("Couldn't load your library").waitFor()
},
pickerApi({ 'GET /v1/media/': { status: 500 } }),
),
Inserted: withSimplifiedEditorException(
pickerState(
async (page) => {
await stubBoardImage(page)
await page.getByText('behind the walls').click()
await page.keyboard.press('End')
await insertFromLibrary(page, 'north-wall.jpg')
await page.waitForTimeout(400)
await selectEditorImage(page)
},
pickerApi({}, pickerBody(null)),
),
),
'Remove image: confirm': withSimplifiedEditorException(
pickerState(requestRemoveFromEditor, pickerApi({}, pickerBody(boardImage, [1]))),
),
Removed: pickerState(
async (page) => {
await requestRemoveFromEditor(page)
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Remove image', exact: true })
.click()
await page.getByText("It's still in your library.").first().waitFor()
},
pickerApi({}, pickerBody(boardImage, [1])),
),
}
export const visualStates: Record<string, Record<string, VisualState>> = {
profile: {
'Account menu': {
@@ -2306,17 +2560,30 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article/12/edit',
now: editorNow,
api: editorApi({
'POST /v1/article/12/image': {
'POST /v1/media/': {
status: 201,
body: { location: '/static/uploadedImages/north-wall.jpg' },
body: {
id: 13,
filename: 'north-wall.jpg',
url: '/static/uploadedImages/north-wall.jpg',
size: mib(3.9),
content_type: 'image/jpeg',
width: 4032,
height: 3024,
sha256: 'a'.repeat(64),
uploaded_by: 'Marta Alves',
created_at: editorNow,
used_in: 0,
duplicate: false,
},
},
}),
steps: async (page) => {
await mockUploadedImage(page)
await page.getByText('add a margin on materials').click()
await page.keyboard.press('Home')
await openImageUploader(page)
await chooseImageFile(page, 'north-wall.jpg', 'image/jpeg', mib(3.9))
await openCropStep(page, 'north-wall.jpg', 'image/jpeg', 4032, 3024, mib(3.9))
await page.getByRole('dialog').getByRole('button', { name: 'Upload image' }).click()
await page.getByText('Save to keep it in the article.').first().waitFor()
await page.waitForTimeout(650)
},
@@ -2514,4 +2781,5 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
},
media: mediaStates,
'media-picker': pickerStates,
}