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(); + }); +});