Feature/281 background removal plan #284

Merged
bermudalamb merged 17 commits from feature/281-background-removal-plan into main 2026-09-03 14:09:52 -05:00
4 changed files with 64 additions and 2 deletions
Showing only changes of commit 1902db6d04 - Show all commits
+19 -1
View File
@@ -8,6 +8,7 @@ import Input from 'antd/es/input';
import Alert from 'antd/es/alert';
import Spin from 'antd/es/spin';
import Space from 'antd/es/space';
import Checkbox from 'antd/es/checkbox';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import { fetchIntakeLink, submitItem } from './intakeApi';
@@ -33,6 +34,10 @@ export default function Submit() {
const [checking, setChecking] = useState(true);
const [files, setFiles] = useState<UploadFile[]>([]);
const [note, setNote] = useState('');
// Ticked by default. Most items look better cut out, and a submitter who
// wants their kitchen table in the photograph can say so — the reverse
// default would mean almost nobody got it.
const [removeBackground, setRemoveBackground] = useState(true);
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -62,7 +67,8 @@ export default function Submit() {
// rather than map-then-filter because a type predicate cannot narrow to
// File here — antd's RcFile extends it, so the predicate would widen.
files.flatMap((f) => (f.originFileObj ? [f.originFileObj] : [])),
note
note,
removeBackground
);
setSending(false);
@@ -129,6 +135,7 @@ export default function Submit() {
onClick={() => {
setFiles([]);
setNote('');
setRemoveBackground(true);
setSent(false);
}}
>
@@ -171,6 +178,17 @@ export default function Submit() {
placeholder="What is it, what is it made of, how big, what condition, where did it come from? Anything you know helps — a photo cannot show any of it."
/>
{state.kind === 'usable' && state.link.backgroundRemoval && (
<Checkbox
checked={removeBackground}
onChange={(e) => setRemoveBackground(e.target.checked)}
>
{/* Described by what it does, not by how. Nobody sending in a
vase knows what a cut-out or an alpha channel is. */}
Remove the background from my photos
</Checkbox>
)}
{error && <Alert type="error" message={error} showIcon />}
<Button type="primary" onClick={send} loading={sending} disabled={files.length === 0}>
+12 -1
View File
@@ -1,5 +1,11 @@
export interface IntakeLink {
label: string;
/**
* Whether there is a background-removal sidecar behind the checkbox. False
* hides it entirely rather than showing a control that would do nothing —
* an unconfigured environment is a working one, not a broken one.
*/
backgroundRemoval: boolean;
}
export type LinkState =
@@ -36,13 +42,18 @@ export type SubmitResult = { ok: true } | { ok: false; error: string };
export async function submitItem(
token: string,
files: File[],
note: string
note: string,
removeBackground: boolean
): Promise<SubmitResult> {
const body = new FormData();
// The field name the server's multer instance listens on. Sending several
// under one name is what makes req.files an array.
for (const file of files) body.append('images', file);
body.append('note', note);
// A string, because that is all a multipart field can be. The server treats
// only the exact 'false' as an opt-out, so this is the one value that has to
// be got right.
body.append('removeBackground', removeBackground ? 'true' : 'false');
const res = await fetch(`/api/intake/${encodeURIComponent(token)}`, {
method: 'POST',
+28
View File
@@ -73,4 +73,32 @@ test.describe('Sending in an item through a link', () => {
// is not on the site.
await expect(page.getByText(/Nothing is listed for sale until/)).toBeVisible();
});
// Ticked by default, because that is the decision: most items look better
// cut out, and the reverse default would mean almost nobody got it.
test('offers to remove the background, already ticked', async ({ page }) => {
await page.goto(`/submit/${token}`);
const control = page.getByRole('checkbox', { name: /remove the background/i });
await expect(control).toBeVisible();
await expect(control).toBeChecked();
});
test('lets a sender turn it off and still send', async ({ page }) => {
await page.goto(`/submit/${token}`);
const png = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64'
);
await page.setInputFiles('input[type="file"]', {
name: `${RUN}-nobg.png`,
mimeType: 'image/png',
buffer: png
});
await page.getByRole('checkbox', { name: /remove the background/i }).uncheck();
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByRole('heading', { name: 'Thank you — it arrived' })).toBeVisible();
});
});
+5
View File
@@ -270,6 +270,11 @@ function Start-Backend {
# No PayPal credentials locally. DEMO_MODE lets the whole cart and checkout
# path run without them and with no way to reach live PayPal.
$env:DEMO_MODE = 'true'
# Needs to be set for the submission page's checkbox to appear at all, but
# not to resolve: no e2e submission reaches the sidecar, because the
# worker only cuts out after a draft and drafting is not configured
# locally.
$env:REMBG_URL = 'http://127.0.0.1:7000'
New-Item -ItemType Directory -Force -Path $env:UPLOADS_DIR *>$null
Write-Step 'Running migrations'