Feature/301 rotate photos #303

Merged
bermudalamb merged 7 commits from feature/301-rotate-photos into main 2026-09-04 14:17:13 -05:00
3 changed files with 120 additions and 1 deletions
Showing only changes of commit dd533ffd63 - Show all commits
+53 -1
View File
@@ -10,6 +10,7 @@ import Empty from 'antd/es/empty';
import Alert from 'antd/es/alert'; import Alert from 'antd/es/alert';
import Modal from 'antd/es/modal'; import Modal from 'antd/es/modal';
import message from 'antd/es/message'; import message from 'antd/es/message';
import { RotateLeftOutlined, RotateRightOutlined } from '@ant-design/icons';
import { import {
Draft, Draft,
DraftImage, DraftImage,
@@ -19,6 +20,7 @@ import {
publishDraft, publishDraft,
setImageBackground setImageBackground
} from './draftsApi'; } from './draftsApi';
import { rotateImage, RotateDirection } from './imagesApi';
const { TextArea } = Input; const { TextArea } = Input;
@@ -52,6 +54,15 @@ function priceLabel(source: PriceSource): string {
* already cut out — leaving a cut-out photo with no control and no way back to * already cut out — leaving a cut-out photo with no control and no way back to
* the original short of hand-editing the database. That breaks the invariant * the original short of hand-editing the database. That breaks the invariant
* this whole feature rests on: the original is always restorable. * this whole feature rests on: the original is always restorable.
*
* The rotation buttons are not gated on `enabled`. That flag is about the
* background-removal sidecar, and rotation has nothing to do with it — turning
* a photo is a local file operation that works in every environment, including
* one where REMBG_URL was never set.
*
* Icon-only, with an aria-label rather than visible text: three labelled
* buttons under a 120px thumbnail is more furniture than the photo, and a
* button with no text has no accessible name at all without one.
*/ */
function DraftPhoto({ function DraftPhoto({
image, image,
@@ -65,8 +76,19 @@ function DraftPhoto({
onChanged: () => void; onChanged: () => void;
}>) { }>) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [turning, setTurning] = useState(false);
const cutOut = image.original_image_path !== null; const cutOut = image.original_image_path !== null;
// Rotation does not change image_path, so after a successful turn the src is
// byte-for-byte the string the browser already holds a copy for, and the
// photo appears not to have moved. express.static is mounted with no maxAge
// and would serve the new bytes on a full page reload — but nothing in a
// session asks it to. This is what makes the button visibly do something. No
// column and no server change: the file's identity has not changed, only this
// page's need to see it again.
const [version, setVersion] = useState(0);
const src = version === 0 ? image.image_path : `${image.image_path}?v=${version}`;
const act = async () => { const act = async () => {
setBusy(true); setBusy(true);
try { try {
@@ -79,9 +101,39 @@ function DraftPhoto({
} }
}; };
// No onChanged(): rotation changes nothing in the queue payload, so
// refetching it would be a request that returns exactly what is on screen.
const turn = async (direction: RotateDirection) => {
setTurning(true);
try {
await rotateImage(itemId, image.id, direction);
setVersion(Date.now());
} catch (err) {
message.error(err instanceof Error ? err.message : 'that did not work');
} finally {
setTurning(false);
}
};
return ( return (
<Space direction="vertical" size={4} align="center"> <Space direction="vertical" size={4} align="center">
<img src={image.image_path} alt="" style={{ width: 120, height: 120, objectFit: 'cover' }} /> <img src={src} alt="" style={{ width: 120, height: 120, objectFit: 'cover' }} />
<Space size={4}>
<Button
size="small"
icon={<RotateLeftOutlined />}
aria-label="Rotate left"
loading={turning}
onClick={() => void turn('left')}
/>
<Button
size="small"
icon={<RotateRightOutlined />}
aria-label="Rotate right"
loading={turning}
onClick={() => void turn('right')}
/>
</Space>
{enabled && ( {enabled && (
<Button size="small" loading={busy} onClick={() => void act()}> <Button size="small" loading={busy} onClick={() => void act()}>
{cutOut ? 'Restore original' : 'Remove background'} {cutOut ? 'Restore original' : 'Remove background'}
+40
View File
@@ -0,0 +1,40 @@
/**
* The client for the item-scoped image endpoints.
*
* Separate from draftsApi.ts, whose `send()` hardcodes the
* `/api/admin/item-drafts` prefix these routes deliberately do not use. The
* review queue is only the first screen to want rotation; the inventory editor
* imports this same module when it follows, which is the whole reason the
* endpoints were put on the item.
*/
export type RotateDirection = 'left' | 'right';
/**
* Turn one photo a quarter turn.
*
* The server's message is preferred over a generic one for the same reason
* publishDraft prefers it: its refusals name the actual problem — a file
* missing from the uploads volume, an animated image that cannot be turned a
* quarter turn — and replacing those with "could not rotate" throws away the
* only thing that says what to do next.
*/
export async function rotateImage(
itemId: number,
imageId: number,
direction: RotateDirection
): Promise<void> {
const res = await fetch(`/api/admin/items/${itemId}/images/${imageId}/rotate-${direction}`, {
method: 'POST'
});
if (res.ok) return;
let message = 'could not rotate 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 in that case.
}
throw new Error(message);
}
@@ -117,4 +117,31 @@ test.describe('The review queue', () => {
const card = page.locator('.ant-card').filter({ hasText: note }); const card = page.locator('.ant-card').filter({ hasText: note });
await expect(card.getByRole('button', { name: 'Remove background' })).toBeVisible(); await expect(card.getByRole('button', { name: 'Remove background' })).toBeVisible();
}); });
// Rotation is what repairs the photos uploaded before #300 taught the
// re-encode to apply EXIF orientation rather than discard it. The 1x1 PNG
// this spec uploads is square, so there is nothing visual to assert — what is
// being proved is that the control is there and the whole path answers
// without an error.
test('offers to rotate each photo', async ({ page, admin }) => {
const note = `Sideways ${RUN}`;
await submitAnItem(page, note);
await admin.open('Review queue');
const card = page.locator('.ant-card').filter({ hasText: note });
await expect(card.getByRole('button', { name: 'Rotate left' })).toBeVisible();
await expect(card.getByRole('button', { name: 'Rotate right' })).toBeVisible();
// Asserted on the response rather than on the absence of an error toast.
// toHaveCount(0) passes the instant it is evaluated, before a failure has
// had time to appear, so it would report success on a broken round trip —
// which is barely an assertion at all. Waiting for the POST proves the
// whole path: the button is wired, the route exists, and it answered 204.
const rotated = page.waitForResponse(
(res) => res.url().includes('/rotate-right') && res.request().method() === 'POST'
);
await card.getByRole('button', { name: 'Rotate right' }).click();
expect((await rotated).status()).toBe(204);
});
}); });