Files
redefined-designs/frontend/src/admin/Admin.tsx
T
bermudalambandClaude Opus 5 64f8efb617 fix(admin): offer Remove and Restore independently (#293)
One button whose label flipped on "is every photo cut out?" could not serve a partly cut-out item, which is not a hypothetical state: it is what a partial removal leaves behind, and it is also what happens when REMBG_URL goes away after some photos were already done. In that state the single button read "Remove backgrounds", so the cut-out photos the item already had could never be restored from this screen. Remove and Restore are now separately gated and can appear together, which is correct — Remove finishes the job on what is left, Restore undoes what is already done.

Restore is deliberately not gated on the backgroundRemoval config flag. Gating it would strand cut-out photos with no way back in exactly the environment that most needs the undo. Remove stays gated, so an unconfigured environment shows no button rather than one that reports zero of four done every time.

The emptiness check moves from `!== null` to `!= null`: original_image_path is optional on the shared Item type because the public storefront response omits it, so a stray undefined has to count as "not cut out" — `undefined !== null` is true, which would misread a public-shaped item as fully cut out.

The modal now refreshes on a non-ok response too. A restore that fails partway can still have swapped some files back before it failed, so returning early left the thumbnails showing files that are no longer on the server. The warning text is now driven off whichever count the action reports, so a partial restore says how far it got the same way a partial removal already did.

The e2e spec seeds its item into a category of its own and filters the table down to it. The inventory table paginates at 10 and the suite runs fullyParallel, so an unfiltered page one was never a reliable place to find the fixture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:02:37 -05:00

512 lines
22 KiB
TypeScript
Executable File

import { useCallback, useEffect, useRef, useState } from 'react';
import Layout from 'antd/es/layout';
import Table from 'antd/es/table';
import Button from 'antd/es/button';
import Drawer from 'antd/es/drawer';
import Form from 'antd/es/form';
import Input from 'antd/es/input';
import InputNumber from 'antd/es/input-number';
import Upload from 'antd/es/upload';
import Modal from 'antd/es/modal';
import Space from 'antd/es/space';
import Tag from 'antd/es/tag';
import Typography from 'antd/es/typography';
import Switch from 'antd/es/switch';
import message from 'antd/es/message';
import AntImage from 'antd/es/image';
import theme from 'antd/es/theme';
import Tabs from 'antd/es/tabs';
import Select from 'antd/es/select';
import { UploadOutlined, DeleteOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload/interface';
import MDEditor from '@uiw/react-md-editor';
import '@uiw/react-md-editor/markdown-editor.css';
import '@uiw/react-markdown-preview/markdown.css';
import {
Item, Category, Tag as TagRecord,
fetchAdminItems, saveItem, deleteItem, deleteItemImage, markSold, markAvailable, unpublishItem,
fetchAdminCategories, fetchAdminTags
} from '../api';
import { useThemeMode } from '../theme/ThemeContext';
import Customers from './Customers';
import Emails from './Emails';
import Settings from './Settings';
import Categories from './Categories';
import Tags from './Tags';
import UploadLinks from './UploadLinks';
import DraftQueue from './DraftQueue';
import BuildStamp from './BuildStamp';
import CategoryTreeSelect from './CategoryTreeSelect';
import ItemCard from '../components/ItemCard';
import InventoryFilters from './InventoryFilters';
import { ItemFilters, EMPTY_FILTERS } from '../filters';
import { uploadUrl } from '../uploadUrl';
const { Header, Content } = Layout;
const { Title } = Typography;
// Anything not sold or reserved is available, so green is the default rather
// than a third entry — a new status shows up green instead of crashing.
// Pending is grey rather than a colour: it is the absence of being published,
// not a state of its own worth drawing the eye to.
const STATUS_TAG_COLORS: Record<string, string> = { sold: 'red', reserved: 'orange', pending: 'default' };
function Inventory() {
const [items, setItems] = useState<Item[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<Item | null>(null);
// The item whose storefront appearance is being previewed, or null when the
// panel is closed. Holds the row object itself — admin and storefront share
// one Item type, so there is nothing to convert and nothing to drift.
const [previewItem, setPreviewItem] = useState<Item | null>(null);
const [form] = Form.useForm();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [description, setDescription] = useState<string>('');
const [categories, setCategories] = useState<Category[]>([]);
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
// arrive out of order and an older one can repaint stale rows over a newer
// result. Only the most recently issued request is allowed to set state.
const latestRequest = useRef(0);
// useCallback rather than a plain function so the effect below can depend on
// it honestly: a function rebuilt every render would either loop forever in
// the dependency array or have to be suppressed out of it.
const load = useCallback((active: ItemFilters = filters) => {
const seq = ++latestRequest.current;
return fetchAdminItems(active)
.then(rows => {
if (seq === latestRequest.current) setItems(rows);
// Handed back so a caller that needs one fresh row (handleBackgrounds)
// can pick it out of this filtered fetch instead of issuing its own
// second, unfiltered one.
return rows;
})
// Without this the table simply keeps showing whatever it had, so a
// failed refetch after a save looks identical to a save that did not
// change anything.
.catch(() => { message.error('Could not load items'); return undefined; });
}, [filters]);
// The item form needs the current category tree and tag list; both change
// from the sibling tabs, so they're refetched whenever the modal opens.
const loadOptions = useCallback(() => Promise.all([
fetchAdminCategories().then(setCategories),
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
// result stays correct regardless of how many items exist.
useEffect(() => { void load(filters); }, [load, filters]);
useEffect(() => { void loadOptions(); }, [loadOptions]);
function applyFilters(next: ItemFilters) { setFilters(next); }
function clearFilters() { setFilters(EMPTY_FILTERS); }
function openNew() {
setEditingItem(null);
form.resetFields();
setFileList([]);
setDescription('');
void loadOptions();
setModalOpen(true);
}
function openEdit(item: Item) {
setEditingItem(item);
form.setFieldsValue({
name: item.name,
price: item.price_cents / 100,
category_id: item.category_id ?? undefined,
tags: item.tags.map(tag => tag.name)
});
setFileList([]);
setDescription(item.description || '');
void loadOptions();
setModalOpen(true);
}
async function handleOk() {
const values = await form.validateFields();
const fd = new FormData();
fd.append('name', values.name);
fd.append('description', description);
fd.append('price', String(values.price));
// An empty string clears the category server-side; undefined would be sent
// as the literal text "undefined".
fd.append('category_id', values.category_id == null ? '' : String(values.category_id));
fd.append('tags', JSON.stringify(values.tags ?? []));
fileList.forEach(f => { if (f.originFileObj) fd.append('images', f.originFileObj as File); });
// Only report success, close the form, and discard the entered values once
// the server has actually accepted the write.
setSaving(true);
try {
await saveItem(editingItem?.id ?? null, fd);
} catch (err) {
message.error(`Couldn't save item — ${(err as Error).message}`);
return;
} finally {
setSaving(false);
}
message.success(editingItem ? 'Item updated' : 'Item added');
setModalOpen(false);
void load();
void loadOptions();
}
// Status changes silently did nothing on failure — the row simply stayed put
// with no indication why.
async function handleStatusChange(action: (id: number) => Promise<Item>, id: number, label: string) {
try {
await action(id);
} catch (err) {
message.error(`Couldn't ${label}${(err as Error).message}`);
return;
}
void load();
}
async function handleDelete(id: number) {
try {
await deleteItem(id);
} catch (err) {
message.error(`Couldn't delete item — ${(err as Error).message}`);
return;
}
message.success('Item deleted');
void load();
}
async function handleDeleteImage(itemId: number, imageId: number) {
try {
await deleteItemImage(itemId, imageId);
} catch (err) {
message.error(`Couldn't remove image — ${(err as Error).message}`);
return;
}
message.success('Image removed');
void load();
setEditingItem(prev => prev && prev.id === itemId
? { ...prev, images: prev.images.filter(img => img.id !== imageId) }
: 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. That
* holds even when the server answers something other than 200: a restore
* that fails partway can still have swapped some files back before it did,
* so the refresh below runs whether the call succeeded or not.
*/
async function handleBackgrounds(itemId: number, action: 'remove-backgrounds' | 'restore-originals') {
const label = action === 'remove-backgrounds' ? '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.');
} else {
const summary = await res.json();
const done = action === 'remove-backgrounds' ? summary.removed : summary.restored;
if (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(`${done} of ${summary.total} photos done. Try again to finish.`);
} else {
message.success('Done.');
}
}
// Re-read the list through load() — same as every other mutation here —
// so a filtered view survives this, then pick this item's fresh images
// back out of it for the open modal. The item's own filtered fields
// (category, tags, status, search) are untouched by a background swap,
// so it stays in the result whenever it was in it before. Run for a
// failed response too — see the doc comment above.
const rows = await load();
const updated = rows?.find(candidate => candidate.id === itemId);
if (updated) setEditingItem(prev => (prev && prev.id === itemId ? updated : prev));
} catch (err) {
message.error(`Couldn't ${label}${(err as Error).message}`);
} finally {
setBusyBackgrounds(false);
}
}
const columns = [
{
title: 'Image',
dataIndex: 'images',
render: (images: Item['images']) =>
images[0] ? (
<span style={{ position: 'relative', display: 'inline-block' }}>
<img src={uploadUrl(images[0].image_path)} alt="" style={{ width: 60 }} />
{images.length > 1 && (
<Tag style={{ position: 'absolute', bottom: -4, right: -8, fontSize: 10 }}>+{images.length - 1}</Tag>
)}
</span>
) : null
},
{
title: 'Name',
dataIndex: 'name',
// A button rather than a clickable cell so it is reachable by keyboard
// and announces itself as an action.
render: (name: string, item: Item) => (
<Button type="link" style={{ padding: 0, height: 'auto', textAlign: 'left' }} onClick={() => setPreviewItem(item)}>
{name}
</Button>
)
},
{
title: 'Category',
dataIndex: 'category_name',
render: (name: string | null) => name || <span style={{ opacity: 0.45 }}>Uncategorized</span>
},
{
title: 'Tags',
dataIndex: 'tags',
render: (itemTags: Item['tags']) =>
itemTags.length
? itemTags.map(tag => <Tag key={tag.id} color={tag.color}>{tag.name}</Tag>)
: null
},
{ title: 'Price', dataIndex: 'price_cents', render: (v: number) => `$${(v / 100).toFixed(2)}` },
{
title: 'Status',
dataIndex: 'status',
render: (status: string) => (
<Tag color={STATUS_TAG_COLORS[status] ?? 'green'}>{status.toUpperCase()}</Tag>
)
},
{
title: 'Actions',
render: (_: unknown, item: Item) => (
<Space>
<Button size="small" onClick={() => openEdit(item)}>Edit</Button>
<Button size="small" danger onClick={() => handleDelete(item.id)}>Delete</Button>
{item.status !== 'sold'
? <Button size="small" onClick={() => handleStatusChange(markSold, item.id, 'mark sold')}>Mark Sold</Button>
: <Button size="small" onClick={() => handleStatusChange(markAvailable, item.id, 'mark available')}>Mark Available</Button>}
{/* Publishing is mark-available under a name that says what it means
here. Unpublish is offered only from available — the server
refuses reserved and sold and says why, and hiding the button in
those states keeps the refusal from being the way you find out. */}
{item.status === 'pending' && (
<Button size="small" type="primary" onClick={() => handleStatusChange(markAvailable, item.id, 'publish')}>
Publish
</Button>
)}
{item.status === 'available' && (
<Button size="small" onClick={() => handleStatusChange(unpublishItem, item.id, 'unpublish')}>
Unpublish
</Button>
)}
</Space>
)
}
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<Title level={4} style={{ margin: 0 }}>Inventory</Title>
<Button type="primary" onClick={openNew}>Add Item</Button>
</div>
<InventoryFilters
categories={categories}
tags={tags}
filters={filters}
onChange={applyFilters}
onClear={clearFilters}
resultCount={items.length}
/>
<Table rowKey="id" dataSource={items} columns={columns} scroll={{ x: true }} />
<Modal title={editingItem ? 'Edit Item' : 'Add Item'} open={modalOpen} onOk={handleOk} confirmLoading={saving} onCancel={() => setModalOpen(false)} destroyOnHidden width={720}>
<div data-color-mode={mode}>
<Form form={form} layout="vertical">
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item label="Description (Markdown supported)">
<MDEditor value={description} onChange={(val) => setDescription(val || '')} height={220} preview="live" />
</Form.Item>
<Form.Item name="price" label="Price (USD)" rules={[{ required: true }]}>
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="category_id" label="Category">
<CategoryTreeSelect categories={categories} onCategoriesChanged={setCategories} />
</Form.Item>
<Form.Item
name="tags"
label="Tags"
extra="Pick existing tags or type a new one and press Enter to create it."
>
<Select
mode="tags"
placeholder="Add tags"
style={{ width: '100%' }}
options={tags.map(tag => ({ value: tag.name, label: tag.name }))}
/>
</Form.Item>
{editingItem && editingItem.images.length > 0 && (
<Form.Item label="Existing Images (front / back / etc.)">
<Space wrap>
{editingItem.images.map(img => (
<div key={img.id} style={{ position: 'relative' }}>
<AntImage src={uploadUrl(img.image_path)} width={80} height={80} style={{ objectFit: 'cover' }} />
<Button size="small" danger icon={<DeleteOutlined />} style={{ position: 'absolute', top: 0, right: 0 }}
onClick={() => handleDeleteImage(editingItem.id, img.id)} />
</div>
))}
</Space>
{(() => {
// `!= null` rather than `!== null`: original_image_path is
// optional on the shared Item type (it is absent from the
// public storefront response), so a stray `undefined` has to
// count as "not cut out" too — `undefined !== null` is `true`,
// which would misread a public-shaped item as fully cut out.
const allCutOut = editingItem.images.every(img => img.original_image_path != null);
const someCutOut = editingItem.images.some(img => img.original_image_path != null);
// Remove and Restore are independent, not two labels for one
// button: a partly cut-out item — a partial removal's normal
// result, or REMBG_URL going away after the fact — needs both,
// or the cut-out photos it already has can never be restored
// from this screen (#293).
const showRemove = backgroundRemoval && !allCutOut;
const showRestore = someCutOut;
if (!showRemove && !showRestore) return null;
return (
<div style={{ marginTop: 8 }}>
<Space>
{showRemove && (
<Button
size="small"
loading={busyBackgrounds}
onClick={() => void handleBackgrounds(editingItem.id, 'remove-backgrounds')}
>
Remove backgrounds
</Button>
)}
{showRestore && (
<Button
size="small"
loading={busyBackgrounds}
onClick={() => void handleBackgrounds(editingItem.id, 'restore-originals')}
>
Restore originals
</Button>
)}
</Space>
</div>
);
})()}
</Form.Item>
)}
<Form.Item label={editingItem ? 'Add More Images' : 'Images (front, back, etc.)'}>
{/* The exact three types the server accepts, not image/*. Offering
a type the API will refuse — SVG, notably — turns a picker
choice into a 400 the admin has to decode. The operating
system's own "All files" option is unaffected: `accept`
chooses the default filter, it does not remove that escape
hatch, and it is not a control either way. The list is
enforced in backend/src/uploadTypes.ts; change both. */}
<Upload accept="image/jpeg,image/png,image/webp"
fileList={fileList} beforeUpload={() => false} onChange={({ fileList }) => setFileList(fileList.slice(-6))}
maxCount={6} multiple listType="picture-card">
<div><UploadOutlined /><div style={{ marginTop: 8 }}>Upload</div></div>
</Upload>
</Form.Item>
</Form>
</div>
</Modal>
{/* The real storefront card, rendered inert. Width is pinned to what the
storefront grid actually gives a card at its widest column, so the
proportions here match what a customer sees rather than stretching to
fill the drawer. */}
<Drawer
title={previewItem ? `Preview: ${previewItem.name}` : 'Preview'}
open={previewItem !== null}
onClose={() => setPreviewItem(null)}
width={420}
destroyOnHidden
>
{previewItem && (
<div style={{ maxWidth: 340, margin: '0 auto' }}>
{/* onChanged never fires: every handler that would call it is
short-circuited by `preview`. */}
{/* A pending item is previewed as it will look once published.
No customer ever sees a pending item, so rendering that state
would answer a question nobody is asking — what is wanted here
is "how will this look when it is live". */}
<ItemCard
item={previewItem.status === 'pending' ? { ...previewItem, status: 'available' } : previewItem}
onChanged={() => undefined}
preview
/>
</div>
)}
</Drawer>
</div>
);
}
export default function Admin() {
const { mode, toggle } = useThemeMode();
const { token } = theme.useToken();
return (
<Layout style={{ minHeight: '100vh' }}>
<Header className="site-header" style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<Title level={3} className="site-header-title" style={{ color: token.colorText }}>Admin</Title>
<div className="site-header-actions">
<BuildStamp />
<Switch checked={mode === 'dark'} onChange={toggle} checkedChildren="Dark" unCheckedChildren="Light" />
</div>
</Header>
<Content style={{ padding: 24 }}>
<Tabs
defaultActiveKey="inventory"
items={[
{ key: 'inventory', label: 'Inventory', children: <Inventory /> },
{ key: 'categories', label: 'Categories', children: <Categories /> },
{ key: 'tags', label: 'Tags', children: <Tags /> },
{ key: 'upload-links', label: 'Upload links', children: <UploadLinks /> },
{ key: 'review-queue', label: 'Review queue', children: <DraftQueue /> },
{ key: 'customers', label: 'Customers', children: <Customers /> },
{ key: 'emails', label: 'Emails', children: <Emails /> },
{ key: 'settings', label: 'Settings', children: <Settings /> }
]}
/>
</Content>
</Layout>
);
}