From 385d5b89bfb7a4946ed722a8e082e6a537043635 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Fri, 4 Sep 2026 10:36:00 -0500 Subject: [PATCH] feat(admin): offer background removal where an item's photos are edited (#293) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One button per item in the inventory editor, beside the per-thumbnail delete buttons rather than on them, because an upload is one item and its photos are views of one thing. Its label is derived from the images rather than stored: Restore originals when every photo already carries an original, Remove backgrounds otherwise. The otherwise deliberately covers the mixed state a partial failure leaves behind — with two of four cut out it reads Remove backgrounds, which is the action that finishes the job, and pressing it skips the two that already worked. Rendering is gated on the feature being configured or every photo already being cut out, not on the flag alone. Gating on the flag would hide Restore originals the moment REMBG_URL is unset, stranding cut-out photos with no way back — the same reasoning the review queue's control already uses. The editor is a modal and this changes files on the server while it is open, so the item is re-read afterwards and the open modal updated. Without that the thumbnails keep showing the previous files and the button looks like it did nothing, which is the bug this was most likely to ship with. frontend/src/api.ts gains original_image_path on the shared Item type's images, since ADMIN_ITEM_SELECT's images aggregate carries it and the public catalogue's does not. It is added as optional rather than required because Item is the same type fetchItems() uses for the public storefront, and a required field the public response never sends would be a type that lies about what is actually there. Co-Authored-By: Claude Opus 5 --- frontend/src/admin/Admin.tsx | 72 ++++++++++++++++++- frontend/src/api.ts | 10 ++- .../tests/e2e/admin-item-backgrounds.spec.ts | 54 ++++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 frontend/tests/e2e/admin-item-backgrounds.spec.ts diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx index 0b115ac..26c4045 100755 --- a/frontend/src/admin/Admin.tsx +++ b/frontend/src/admin/Admin.tsx @@ -66,6 +66,11 @@ function Inventory() { const [tags, setTags] = useState([]); const [saving, setSaving] = useState(false); const [filters, setFilters] = useState(EMPTY_FILTERS); + // Whether this environment has a background-removal sidecar. False hides the + // control rather than offering one that would report zero of four done every + // time — an unconfigured environment is a working one, not a broken one. + const [backgroundRemoval, setBackgroundRemoval] = useState(false); + const [busyBackgrounds, setBusyBackgrounds] = useState(false); const { mode } = useThemeMode(); // Typing in the price fields fires a request per keystroke, so responses can @@ -91,7 +96,11 @@ function Inventory() { // from the sibling tabs, so they're refetched whenever the modal opens. const loadOptions = useCallback(() => Promise.all([ fetchAdminCategories().then(setCategories), - fetchAdminTags().then(setTags) + fetchAdminTags().then(setTags), + fetch('/api/admin/config') + .then((res) => (res.ok ? res.json() : { backgroundRemoval: false })) + .then((config) => setBackgroundRemoval(config.backgroundRemoval)) + .catch(() => setBackgroundRemoval(false)) ]).catch(() => message.error('Could not load categories and tags')), []); // Refetch whenever the filters change — filtering is server-side so the @@ -192,6 +201,49 @@ function Inventory() { : prev); } + /** + * Cut out every photo of the item being edited, or put every original back. + * + * Per item, not per photo: an upload is one item, and its photos are views of + * one thing (#293). + * + * The response replaces the open modal's images rather than being trusted to + * have changed nothing else. The editor is a modal and this changes files on + * the server while it is open, so without a refresh the thumbnails keep + * showing the previous files and the button looks like it did nothing. + */ + async function handleBackgrounds(itemId: number, action: 'remove-backgrounds' | 'restore-originals') { + setBusyBackgrounds(true); + try { + const res = await fetch(`/api/admin/items/${itemId}/${action}`, { method: 'POST' }); + if (!res.ok) { + message.error('That did not work.'); + return; + } + const summary = await res.json(); + + if (action === 'remove-backgrounds' && summary.failed) { + // Said plainly rather than as a generic failure. How far it got is what + // decides whether pressing it again is worth anything, and it is — + // a retry skips the ones that already worked. + message.warning(`${summary.removed} of ${summary.total} photos done. Try again to finish.`); + } else { + message.success('Done.'); + } + + // Re-read the item so the thumbnails match what is now on the server. + const fresh = await fetch('/api/admin/items'); + if (fresh.ok) { + const all: Item[] = await fresh.json(); + const updated = all.find((candidate) => candidate.id === itemId); + if (updated) setEditingItem(prev => (prev && prev.id === itemId ? updated : prev)); + setItems(all); + } + } finally { + setBusyBackgrounds(false); + } + } + const columns = [ { title: 'Image', @@ -322,6 +374,24 @@ function Inventory() { ))} + {(backgroundRemoval || editingItem.images.every(img => img.original_image_path !== null)) && ( +
+ +
+ )} )} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 680c02c..af72130 100755 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -13,7 +13,15 @@ export interface Item { name: string; description: string | null; price_cents: number; - images: { id: number; image_path: string; sort_order: number }[]; + images: { + id: number; + image_path: string; + sort_order: number; + // Present on admin responses only — ADMIN_ITEM_SELECT's images aggregate + // carries it, PUBLIC_ITEM_SELECT's does not — so it stays optional on this + // shared type rather than a lie the public fetchItems() response can't back up. + original_image_path?: string | null; + }[]; status: 'pending' | 'available' | 'reserved' | 'sold'; category_id: number | null; category_name: string | null; diff --git a/frontend/tests/e2e/admin-item-backgrounds.spec.ts b/frontend/tests/e2e/admin-item-backgrounds.spec.ts new file mode 100644 index 0000000..7f374b8 --- /dev/null +++ b/frontend/tests/e2e/admin-item-backgrounds.spec.ts @@ -0,0 +1,54 @@ +import { test, expect, createAdminContext, uniqueSuffix } from './fixtures'; + +/** + * The per-item background control in the inventory editor (#293). + * + * Asserts the control is offered, not that a cut-out happens. Pressing it needs + * a live rembg sidecar, which takes forty seconds to start and which no test + * should depend on — the swap itself is covered in the integration suite + * against a stub. + * + * "Does not appear when the feature is unconfigured" is not written here as an + * e2e case: unsetting REMBG_URL means restarting the backend mid-suite, which + * this run has no way to do and should not gain one. GET /api/admin/config + * answering `backgroundRemoval: false` is covered by Task 2's integration + * tests, and the render condition that gates the button on it is plain enough + * to read in Admin.tsx. + */ +const RUN = uniqueSuffix(); +const NAME = `Vase ${RUN}`; + +// The 1x1 PNG the other upload specs use, so this goes through the real +// validated upload path rather than a buffer that merely starts correctly. +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', + 'base64' +); + +test.beforeAll(async ({ playwright }) => { + const api = await createAdminContext(playwright); + // Seeded with an image directly: createItem cannot attach one, and the + // control does not render for an item with no photos. + const res = await api.post('/api/admin/items', { + multipart: { + name: NAME, + description: '', + price: '50', + category_id: '', + tags: '[]', + images: { name: `${RUN}.png`, mimeType: 'image/png', buffer: PNG } + } + }); + expect(res.ok(), `seeding ${NAME}`).toBeTruthy(); + await api.dispose(); +}); + +test.describe('Removing backgrounds from the item editor', () => { + test('offers the control on an item that has photos', async ({ page, admin }) => { + await admin.open('Inventory'); + + await page.getByRole('row', { name: new RegExp(NAME) }).getByRole('button', { name: 'Edit' }).click(); + + await expect(page.getByRole('button', { name: 'Remove backgrounds' })).toBeVisible(); + }); +});