feat(admin): remove or restore a photo's background from the review queue (#281)
Adds the per-photo control that closes out the background-removal feature: each photo in the review queue now gets a "Remove background" or "Restore original" button, whichever matches its current state, and the button only appears when the server reports a sidecar is configured. The label is read from original_image_path alone rather than a second flag, so there is nothing that could disagree with what the button actually does.
draftsApi.ts's fetchDrafts now returns { drafts, backgroundRemoval } instead of a bare Draft[], matching the breaking change Task 6 made to GET /api/admin/item-drafts. DraftImage gains original_image_path, and a new setImageBackground(itemId, imageId, action) posts to the remove-background/restore-original endpoints, preferring the server's error message the same way publishDraft does.
Also updates docs/ops/image-background-removal-stack.md: the status line no longer says "evaluated, not adopted", since the feature is adopted here, and the closing "If this is adopted" section is replaced with "How the application uses it", describing the two real entry points (the drafting worker's default-on checkbox, and this per-photo control) and confirming that nothing in the feature deletes a file or a row.
Adds an e2e case asserting the button's label appears on a freshly submitted item's card, scoped to that card by the sender's note per #241. It is unrun in this environment — the local stack was not started, per standing instruction not to run start-local.ps1 or Playwright without the user's supervision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,15 @@ import Empty from 'antd/es/empty';
|
||||
import Alert from 'antd/es/alert';
|
||||
import Modal from 'antd/es/modal';
|
||||
import message from 'antd/es/message';
|
||||
import { Draft, PriceSource, actOnDraft, fetchDrafts, publishDraft } from './draftsApi';
|
||||
import {
|
||||
Draft,
|
||||
DraftImage,
|
||||
PriceSource,
|
||||
actOnDraft,
|
||||
fetchDrafts,
|
||||
publishDraft,
|
||||
setImageBackground
|
||||
} from './draftsApi';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
@@ -25,6 +33,54 @@ function priceLabel(source: PriceSource): string {
|
||||
return 'default price — nobody chose this';
|
||||
}
|
||||
|
||||
/**
|
||||
* One photo, with the control that cuts it out or puts it back.
|
||||
*
|
||||
* Per photo rather than per item because that is how a poor result is undone:
|
||||
* background removal produces the occasional bad cut on an unusual object, and
|
||||
* the answer is to restore that one photograph, not to unpick the submission.
|
||||
*
|
||||
* The label is the state. `original_image_path` is the only thing consulted,
|
||||
* so there is no second flag that could disagree with what the button does.
|
||||
*/
|
||||
function DraftPhoto({
|
||||
image,
|
||||
itemId,
|
||||
enabled,
|
||||
onChanged
|
||||
}: Readonly<{
|
||||
image: DraftImage;
|
||||
itemId: number;
|
||||
enabled: boolean;
|
||||
onChanged: () => void;
|
||||
}>) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const cutOut = image.original_image_path !== null;
|
||||
|
||||
const act = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await setImageBackground(itemId, image.id, cutOut ? 'restore-original' : 'remove-background');
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'that did not work');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={4} align="center">
|
||||
<img src={image.image_path} alt="" style={{ width: 120, height: 120, objectFit: 'cover' }} />
|
||||
{enabled && (
|
||||
<Button size="small" loading={busy} onClick={() => void act()}>
|
||||
{cutOut ? 'Restore original' : 'Remove background'}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One submission, with everything needed to judge it.
|
||||
*
|
||||
@@ -33,7 +89,11 @@ function priceLabel(source: PriceSource): string {
|
||||
* so — and 80.00 is a plausible price rather than an obvious sentinel, which is
|
||||
* exactly why it has to be called out rather than left to be noticed.
|
||||
*/
|
||||
function DraftCard({ draft, onChanged }: Readonly<{ draft: Draft; onChanged: () => void }>) {
|
||||
function DraftCard({
|
||||
draft,
|
||||
backgroundRemoval,
|
||||
onChanged
|
||||
}: Readonly<{ draft: Draft; backgroundRemoval: boolean; onChanged: () => void }>) {
|
||||
const [name, setName] = useState(draft.ai_name ?? draft.item_name);
|
||||
const [description, setDescription] = useState(
|
||||
draft.ai_description ?? draft.item_description ?? ''
|
||||
@@ -82,13 +142,14 @@ function DraftCard({ draft, onChanged }: Readonly<{ draft: Draft; onChanged: ()
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
{draft.ai_error && <Alert type="warning" message={`Drafting failed: ${draft.ai_error}`} />}
|
||||
|
||||
<Space wrap>
|
||||
<Space wrap align="start">
|
||||
{draft.images.map((image) => (
|
||||
<img
|
||||
<DraftPhoto
|
||||
key={image.id}
|
||||
src={image.image_path}
|
||||
alt=""
|
||||
style={{ width: 120, height: 120, objectFit: 'cover' }}
|
||||
image={image}
|
||||
itemId={draft.item_id}
|
||||
enabled={backgroundRemoval}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
@@ -148,12 +209,15 @@ function DraftCard({ draft, onChanged }: Readonly<{ draft: Draft; onChanged: ()
|
||||
|
||||
export default function DraftQueue() {
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
const [backgroundRemoval, setBackgroundRemoval] = useState(false);
|
||||
const [state, setState] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setDrafts(await fetchDrafts(state));
|
||||
const payload = await fetchDrafts(state);
|
||||
setDrafts(payload.drafts);
|
||||
setBackgroundRemoval(payload.backgroundRemoval);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError('Could not load the review queue.');
|
||||
@@ -188,7 +252,12 @@ export default function DraftQueue() {
|
||||
{error && <Alert type="error" message={error} />}
|
||||
{!error && drafts.length === 0 && <Empty description="Nothing waiting for review" />}
|
||||
{drafts.map((draft) => (
|
||||
<DraftCard key={draft.item_id} draft={draft} onChanged={() => void load()} />
|
||||
<DraftCard
|
||||
key={draft.item_id}
|
||||
draft={draft}
|
||||
backgroundRemoval={backgroundRemoval}
|
||||
onChanged={() => void load()}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,12 @@ export type PriceSource = 'default' | 'ai' | 'admin';
|
||||
export interface DraftImage {
|
||||
id: number;
|
||||
image_path: string;
|
||||
/**
|
||||
* Where this photo came from, once it has been cut out. Null means it never
|
||||
* was — which is also the answer to whether Restore has anything to do, so
|
||||
* there is no second flag that could disagree with it.
|
||||
*/
|
||||
original_image_path: string | null;
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
@@ -31,11 +37,21 @@ async function send(path: string, init?: RequestInit): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDrafts(state?: string): Promise<Draft[]> {
|
||||
export interface DraftQueueResponse {
|
||||
drafts: Draft[];
|
||||
/**
|
||||
* Whether a background-removal sidecar is configured. False hides the
|
||||
* control rather than showing one that would answer 502 — an environment
|
||||
* without a sidecar is a working environment.
|
||||
*/
|
||||
backgroundRemoval: boolean;
|
||||
}
|
||||
|
||||
export async function fetchDrafts(state?: string): Promise<DraftQueueResponse> {
|
||||
const query = state ? `?state=${encodeURIComponent(state)}` : '';
|
||||
const res = await send(query);
|
||||
if (!res.ok) throw new Error('could not load the review queue');
|
||||
return (await res.json()).drafts;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface PublishInput {
|
||||
@@ -71,3 +87,29 @@ export async function actOnDraft(
|
||||
const res = await send(`/${itemId}/${action}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`could not ${action} this draft`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cut one photo out, or put its original back.
|
||||
*
|
||||
* The server's message is preferred over a generic one for the same reason
|
||||
* publishDraft prefers it: a 502 here says the removal service did not answer
|
||||
* and the photo is unchanged, which is the difference between "try again" and
|
||||
* "something is wrong with this item".
|
||||
*/
|
||||
export async function setImageBackground(
|
||||
itemId: number,
|
||||
imageId: number,
|
||||
action: 'remove-background' | 'restore-original'
|
||||
): Promise<void> {
|
||||
const res = await send(`/${itemId}/images/${imageId}/${action}`, { method: 'POST' });
|
||||
if (res.ok) return;
|
||||
|
||||
let message = 'could not change this photo';
|
||||
try {
|
||||
message = (await res.json()).error ?? message;
|
||||
} catch {
|
||||
// A non-JSON body is a proxy or gateway error rather than the app
|
||||
// refusing. The generic message is the honest thing to show.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user