Files
redefined-designs/frontend/src/admin/Admin.tsx
T
bermudalamb 44328d0b5c
Linting / lint (pull_request) Successful in 2m38s
SonarQube Analysis / sonarqube (pull_request) Failing after 29m40s
feat(admin): show the deployed commit and build time in the admin (#233)
There was no way to tell which build an environment was running. That is not hypothetical: minutes after #232 merged, `npm run backfill:images` in QA failed with `tsx: not found` because the container was still serving the pre-merge image, and the only thing that revealed it was npm echoing the old script line. Had the change been anywhere other than a package.json script, the container would have looked healthy while running the wrong code.

The header now reads something like `a5076cc · built 29 Aug 20:36`. The commit answers "is this the code I expect"; the build time answers "did my redeploy actually rebuild", which is a different question and the one that would have caught the case above.

The commit is read out of `.git` directly rather than by shelling out, because node:20-bookworm-slim has no git binary and adding an apt layer so the image can print seven characters is a poor trade. `.git` is copied into the build stage only — verified absent from the final image — so no repository history reaches a deployed container.

Resolution is pure and separately tested across every shape that actually occurs: a detached HEAD holding the object name, which is what a checkout of a ref produces; a symbolic HEAD followed to a loose ref file; the same followed to packed-refs, which is what a fresh clone commonly has; peeled `^` tag lines ignored so an annotated tag cannot yield the wrong commit; and every failure path returning `unknown`. That last part is the one that matters most — this runs during a Docker build, and a version stamp must never be the thing that stops a deploy.

Served from a gated /api/admin/version rather than folded into /api/config. That endpoint is public, and a commit hash there would tell any storefront visitor exactly which revision of a public repository is deployed. An integration test asserts the gate and asserts the public config does not carry it, because the boundary is the whole point rather than an implementation detail.

Verified in the built image rather than argued: the stamp inside it reads a5076cc, matching `git rev-parse --short HEAD`, and a running container serves it from /api/admin/version while /api/config returns only what it did before.

Backend: 296 unit, 263 integration, tsc clean, lint unchanged at six pre-existing warnings. Frontend builds clean with its two pre-existing warnings untouched.

Closes #233
2026-08-29 15:37:12 -05:00

403 lines
16 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 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);
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);
})
// 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'));
}, [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)
]).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);
}
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>
</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: 'customers', label: 'Customers', children: <Customers /> },
{ key: 'emails', label: 'Emails', children: <Emails /> },
{ key: 'settings', label: 'Settings', children: <Settings /> }
]}
/>
</Content>
</Layout>
);
}