Feature/293 remove backgrounds from inventory #296

Merged
bermudalamb merged 9 commits from feature/293-remove-backgrounds-from-inventory into main 2026-09-04 12:20:01 -05:00
3 changed files with 134 additions and 2 deletions
Showing only changes of commit 385d5b89bf - Show all commits
+71 -1
View File
@@ -66,6 +66,11 @@ function Inventory() {
const [tags, setTags] = useState<TagRecord[]>([]);
const [saving, setSaving] = useState(false);
const [filters, setFilters] = useState<ItemFilters>(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() {
</div>
))}
</Space>
{(backgroundRemoval || editingItem.images.every(img => img.original_image_path !== null)) && (
<div style={{ marginTop: 8 }}>
<Button
size="small"
loading={busyBackgrounds}
onClick={() => void handleBackgrounds(
editingItem.id,
editingItem.images.every(img => img.original_image_path !== null)
? 'restore-originals'
: 'remove-backgrounds'
)}
>
{editingItem.images.every(img => img.original_image_path !== null)
? 'Restore originals'
: 'Remove backgrounds'}
</Button>
</div>
)}
</Form.Item>
)}
<Form.Item label={editingItem ? 'Add More Images' : 'Images (front, back, etc.)'}>
+9 -1
View File
@@ -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;
@@ -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();
});
});