This repository has been archived on 2026-09-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
itsthevine/frontend/src/pages/Admin.tsx
T
austin 24d26c2bc9 Catering tables: the spreadsheet becomes data the bakery can edit
The goodie box and catering prices arrived as a spreadsheet — Office, Parties and Weddings, each a
few columns of sizes and prices with lines of baked goods underneath. This puts it behind
/api/catering and makes every part of it editable at /admin, because the prices move and the
spreadsheet's own last line says the tables are "mostly just an idea for people".

A package is one table, its tiers are the columns, its rows are the lines, and a line holds one
value per column. That alignment is why this is an aggregate rather than three tables edited
separately: drop the middle column on its own and every remaining entry shifts one place left, so
the Large box advertises the Medium box's contents at the Large price and nothing looks broken.
CateringPackage#arrange takes a whole table, renumbers positions from the order it arrived in, and
refuses an arrangement whose lines and columns disagree.

Money owns prices — what "24", "$24" or "24.50" means and how it prints — so the browser never
formats money and never multiplies it by 100 in floating point. Cents in the column, "$24" in the
response. An empty price is "ask us", not zero.

Seeded from the bakery's own wording. Shorthand is expanded ("4 dz cc or sc") and typos fixed, since
customers read these lines; in the wedding table the labels and the values are offset in the source
spreadsheet, so they are carried over literally and can be renamed in the admin. The lines that are
named but never quantified keep their blank cells: dropping the blanks would shorten the line and
shift everything after it.

The public response leaves out a table with no columns or no lines — adding a table and filling it
in are two separate acts, and the gap between them shouldn't put a bare heading on the live page.
No public page renders any of this yet; this is the backend and the editor for it.

Admin endpoints are @ConditionalOnProperty on SECURITY_MODE=OIDC like the rest, so a deployment with
no identity provider has no price writes. 18 new tests: the seeded spreadsheet, the alignment
invariant, money in both directions, and the HTTP surface the screen actually calls (including that
/packages/order isn't read as a table id, and that a refusal arrives as a ProblemDetail sentence).
2026-07-26 15:13:39 -05:00

691 lines
22 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import {
addPhotos,
adminCategories,
adminProducts,
arrangePhotos,
createCategory,
createProduct,
deleteCategory,
deleteProduct,
describeProduct,
renameCategory,
reorderCategories,
reorderProducts,
type AdminCategory,
type AdminProduct,
} from '@/lib/api';
import Catering from '@/components/admin/Catering';
import {
ARROW_DOWN,
ARROW_LEFT,
ARROW_RIGHT,
ARROW_UP,
CHECK,
Icon,
PLUS,
TRASH,
X,
danger,
field,
iconButton,
primary,
secondary,
shift,
} from '@/components/admin/ui';
/**
* The catalogue, editable by the person who bakes it.
*
* The whole screen is built around one rule: nothing waits on the network to look like it happened.
* Reordering and deleting apply to the list on screen first and reconcile afterwards, because an
* editor tidying twenty items shouldn't be typing into a page that freezes between every click.
* Uploads are the exception — they genuinely take a moment (resize, convert, send), so they say so.
*
* Getting here at all means signing in: /admin is an authenticated path, so an unknown visitor is
* sent to the identity provider before this ever loads.
*/
// --- photos -----------------------------------------------------------------
/**
* The photos on one item. Order matters — the first is the one the products page leads with — so
* arranging is left/right rather than a drag target, which is far easier to hit on a phone.
*/
const Photos = ({
product,
onArrange,
busy,
}: {
product: AdminProduct;
onArrange: (keys: string[]) => void;
busy: boolean;
}) => (
<div className="flex flex-wrap gap-3">
{product.images.map((url, i) => (
<figure key={product.keys[i] ?? url} className="w-28">
<img
src={url}
alt=""
loading="lazy"
className="w-28 h-28 rounded-md object-cover border border-bakery-200 bg-bakery-100"
/>
<figcaption className="mt-1 flex items-center justify-between gap-1">
<div className="flex gap-1">
<button
type="button"
className={iconButton}
disabled={busy || i === 0}
onClick={() => onArrange(shift(product.keys, i, -1))}
aria-label="Move photo earlier"
>
<Icon d={ARROW_LEFT} />
</button>
<button
type="button"
className={iconButton}
disabled={busy || i === product.images.length - 1}
onClick={() => onArrange(shift(product.keys, i, 1))}
aria-label="Move photo later"
>
<Icon d={ARROW_RIGHT} />
</button>
</div>
<button
type="button"
className={iconButton}
// The server refuses an empty arrangement; saying so up front beats an error message.
disabled={busy || product.images.length === 1}
onClick={() => onArrange(product.keys.filter((_, at) => at !== i))}
aria-label="Remove photo"
title={product.images.length === 1 ? 'An item needs at least one photo' : 'Remove photo'}
>
<Icon d={TRASH} />
</button>
</figcaption>
</figure>
))}
</div>
);
/** A file picker styled as a button, resetting itself so the same file can be chosen twice. */
const PhotoPicker = ({
label,
onPick,
disabled,
className = secondary,
}: {
label: string;
onPick: (files: File[]) => void;
disabled?: boolean;
className?: string;
}) => {
const input = useRef<HTMLInputElement>(null);
return (
<>
<input
ref={input}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
e.target.value = '';
if (files.length) onPick(files);
}}
/>
<button type="button" className={className} disabled={disabled} onClick={() => input.current?.click()}>
<Icon d={PLUS} />
{label}
</button>
</>
);
};
// --- one item ---------------------------------------------------------------
const ProductCard = ({
product,
categories,
first,
last,
onChange,
onMove,
onDelete,
onError,
}: {
product: AdminProduct;
categories: string[];
first: boolean;
last: boolean;
onChange: (updated: AdminProduct) => void;
onMove: (delta: number) => void;
onDelete: () => void;
onError: (message: string) => void;
}) => {
const [name, setName] = useState(product.name);
const [category, setCategory] = useState(product.category);
const [busy, setBusy] = useState(false);
// A reorder or an upload re-fetches this product; the fields should follow unless they're being
// edited, which is what the dirty check below decides.
const dirty = name !== product.name || category !== product.category;
const [synced, setSynced] = useState(product);
if (synced !== product) {
setSynced(product);
if (!dirty) {
setName(product.name);
setCategory(product.category);
}
}
const run = async (work: () => Promise<AdminProduct>) => {
setBusy(true);
try {
onChange(await work());
} catch (e) {
onError(e instanceof Error ? e.message : 'That did not save.');
} finally {
setBusy(false);
}
};
return (
<li className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="flex sm:flex-col gap-1 sm:pt-1">
<button
type="button"
className={iconButton}
disabled={first}
onClick={() => onMove(-1)}
aria-label={`Move ${product.name} up`}
>
<Icon d={ARROW_UP} />
</button>
<button
type="button"
className={iconButton}
disabled={last}
onClick={() => onMove(1)}
aria-label={`Move ${product.name} down`}
>
<Icon d={ARROW_DOWN} />
</button>
</div>
<div className="flex-1 min-w-0 space-y-3">
<div className="grid gap-2 sm:grid-cols-[1fr_12rem]">
<label className="block">
<span className="sr-only">Name</span>
<input
className={field}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
</label>
<label className="block">
<span className="sr-only">Category</span>
<select className={field} value={category} onChange={(e) => setCategory(e.target.value)}>
{/* A product can sit in a category nobody defined; don't silently retype it. */}
{!categories.includes(category) && <option value={category}>{category}</option>}
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
</div>
<Photos
product={product}
busy={busy}
onArrange={(keys) => run(() => arrangePhotos(product.id, keys))}
/>
<div className="flex flex-wrap items-center gap-2">
<PhotoPicker
label="Add photos"
disabled={busy}
onPick={(files) => run(() => addPhotos(product.id, files))}
/>
{dirty && (
<>
<button
type="button"
className={primary}
disabled={busy}
onClick={() => run(() => describeProduct(product.id, name.trim(), category))}
>
<Icon d={CHECK} />
Save
</button>
<button
type="button"
className={secondary}
disabled={busy}
onClick={() => {
setName(product.name);
setCategory(product.category);
}}
>
<Icon d={X} />
Cancel
</button>
</>
)}
<button type="button" className={`${danger} ml-auto`} disabled={busy} onClick={onDelete}>
<Icon d={TRASH} />
Delete
</button>
</div>
{busy && <p className="text-sm text-bakery-600">Working</p>}
</div>
</div>
</li>
);
};
// --- adding an item ---------------------------------------------------------
const NewItem = ({
categories,
onAdded,
onError,
}: {
categories: string[];
onAdded: (product: AdminProduct) => void;
onError: (message: string) => void;
}) => {
const [name, setName] = useState('');
const [category, setCategory] = useState(categories[0] ?? '');
const [files, setFiles] = useState<File[]>([]);
const [busy, setBusy] = useState(false);
// The category list arrives after the first render, so the default has to catch up once.
useEffect(() => {
setCategory((c) => (c || categories[0] || ''));
}, [categories]);
const submit = async () => {
setBusy(true);
try {
onAdded(await createProduct(name.trim(), category, files));
setName('');
setFiles([]);
} catch (e) {
onError(e instanceof Error ? e.message : 'That did not save.');
} finally {
setBusy(false);
}
};
return (
<section className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
<h2 className="font-adbhashitha text-xl text-bakery-800">Add something new</h2>
<p className="mt-1 text-sm text-bakery-600">New items go to the top of the products page.</p>
<div className="mt-3 grid gap-2 sm:grid-cols-[1fr_12rem]">
<input
className={field}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="What is it? e.g. Chocolate drip cake"
/>
<select className={field} value={category} onChange={(e) => setCategory(e.target.value)}>
{categories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
{files.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{files.map((file, i) => (
<div key={`${file.name}-${i}`} className="relative">
<img
src={URL.createObjectURL(file)}
alt=""
className="w-20 h-20 rounded-md object-cover border border-bakery-200"
// Revoking once it's painted keeps the preview from holding the file in memory.
onLoad={(e) => URL.revokeObjectURL(e.currentTarget.src)}
/>
<button
type="button"
className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-white border border-bakery-300 text-bakery-700 flex items-center justify-center"
onClick={() => setFiles(files.filter((_, at) => at !== i))}
aria-label={`Remove ${file.name}`}
>
<Icon d={X} className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<PhotoPicker
label={files.length ? 'Add more photos' : 'Choose photos'}
disabled={busy}
onPick={(picked) => setFiles((current) => [...current, ...picked])}
/>
<button
type="button"
className={primary}
disabled={busy || !name.trim() || !category || files.length === 0}
onClick={submit}
>
<Icon d={PLUS} />
{busy ? 'Uploading…' : 'Add to the page'}
</button>
{busy && <span className="text-sm text-bakery-600">Photos can take a few seconds each.</span>}
</div>
</section>
);
};
// --- categories -------------------------------------------------------------
const Categories = ({
categories,
setCategories,
onError,
onChanged,
}: {
categories: AdminCategory[];
setCategories: (next: AdminCategory[]) => void;
onError: (message: string) => void;
onChanged: () => void;
}) => {
const [fresh, setFresh] = useState('');
const [editing, setEditing] = useState<number | null>(null);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
const guard = async (work: () => Promise<unknown>, optimistic?: AdminCategory[]) => {
const before = categories;
if (optimistic) setCategories(optimistic);
setBusy(true);
try {
await work();
onChanged();
} catch (e) {
if (optimistic) setCategories(before);
onError(e instanceof Error ? e.message : 'That did not save.');
} finally {
setBusy(false);
}
};
return (
<section className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
<h2 className="font-adbhashitha text-xl text-bakery-800">Categories</h2>
<p className="mt-1 text-sm text-bakery-600">
These are the filter buttons on the products page, in this order. Renaming one moves
everything filed under it too.
</p>
<ul className="mt-3 divide-y divide-bakery-100">
{categories.map((category, i) => (
<li key={category.id} className="flex items-center gap-2 py-2">
<div className="flex gap-1">
<button
type="button"
className={iconButton}
disabled={busy || i === 0}
onClick={() =>
guard(
() => reorderCategories(shift(categories, i, -1).map((c) => c.id)),
shift(categories, i, -1),
)
}
aria-label={`Move ${category.name} up`}
>
<Icon d={ARROW_UP} />
</button>
<button
type="button"
className={iconButton}
disabled={busy || i === categories.length - 1}
onClick={() =>
guard(
() => reorderCategories(shift(categories, i, 1).map((c) => c.id)),
shift(categories, i, 1),
)
}
aria-label={`Move ${category.name} down`}
>
<Icon d={ARROW_DOWN} />
</button>
</div>
{editing === category.id ? (
<>
<input
className={field}
value={draft}
autoFocus
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Escape' && setEditing(null)}
/>
<button
type="button"
className={primary}
disabled={busy || !draft.trim()}
onClick={() =>
guard(async () => {
await renameCategory(category.id, draft.trim());
setEditing(null);
})
}
>
<Icon d={CHECK} />
Save
</button>
<button type="button" className={secondary} onClick={() => setEditing(null)}>
<Icon d={X} />
</button>
</>
) : (
<>
<span className="flex-1 text-bakery-900">{category.name}</span>
<span className="text-sm text-bakery-500">
{category.used} item{category.used === 1 ? '' : 's'}
</span>
<button
type="button"
className={secondary}
onClick={() => {
setEditing(category.id);
setDraft(category.name);
}}
>
Rename
</button>
<button
type="button"
className={danger}
disabled={busy || category.used > 0}
title={category.used > 0 ? 'Move its items somewhere else first' : 'Delete'}
onClick={() =>
guard(
() => deleteCategory(category.id),
categories.filter((c) => c.id !== category.id),
)
}
>
<Icon d={TRASH} />
</button>
</>
)}
</li>
))}
</ul>
<div className="mt-3 flex gap-2">
<input
className={field}
value={fresh}
onChange={(e) => setFresh(e.target.value)}
placeholder="New category"
onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && guard(async () => {
await createCategory(fresh.trim());
setFresh('');
})}
/>
<button
type="button"
className={primary}
disabled={busy || !fresh.trim()}
onClick={() =>
guard(async () => {
await createCategory(fresh.trim());
setFresh('');
})
}
>
<Icon d={PLUS} />
Add
</button>
</div>
</section>
);
};
// --- the page ---------------------------------------------------------------
const AdminPage = () => {
const [products, setProducts] = useState<AdminProduct[] | null>(null);
const [categories, setCategories] = useState<AdminCategory[]>([]);
const [error, setError] = useState('');
const load = useCallback(async () => {
try {
const [items, cats] = await Promise.all([adminProducts(), adminCategories()]);
setProducts(items);
setCategories(cats);
} catch (e) {
setError(e instanceof Error ? e.message : 'Could not load the catalogue.');
setProducts([]);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const names = categories.map((c) => c.name);
/** Reorder and delete both apply on screen first — the point of this page is that it keeps up. */
const settle = async (optimistic: AdminProduct[], work: () => Promise<unknown>) => {
const before = products ?? [];
setProducts(optimistic);
try {
await work();
} catch (e) {
setProducts(before);
setError(e instanceof Error ? e.message : 'That did not save.');
}
};
return (
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-lejour text-4xl text-bakery-700">The Vine</h1>
<p className="text-bakery-600">Everything on the products and catering pages lives here.</p>
</div>
<div className="flex items-center gap-2">
<a href="/products" className={secondary}>
View the page
</a>
{/* A real form post: the platform's logout expects one, and it also ends the Authentik session. */}
<form method="post" action="/logout">
<button type="submit" className={secondary}>
Sign out
</button>
</form>
</div>
</header>
{error && (
<div
role="alert"
className="mt-6 flex items-start gap-3 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800"
>
<span className="flex-1">{error}</span>
<button type="button" onClick={() => setError('')} aria-label="Dismiss">
<Icon d={X} />
</button>
</div>
)}
{products === null ? (
<p className="mt-10 text-bakery-600">Loading</p>
) : (
<div className="mt-6 space-y-6">
<Categories
categories={categories}
setCategories={setCategories}
onError={setError}
// A rename rewrites the products filed under it, so the list has to come back fresh.
onChanged={() => void load()}
/>
<NewItem
categories={names}
onError={setError}
onAdded={(product) => setProducts([product, ...products])}
/>
<section>
<h2 className="font-adbhashitha text-xl text-bakery-800">
On the page ({products.length})
</h2>
<ul className="mt-3 space-y-3">
{products.map((product, i) => (
<ProductCard
key={product.id}
product={product}
categories={names}
first={i === 0}
last={i === products.length - 1}
onError={setError}
onChange={(updated) =>
setProducts(products.map((p) => (p.id === updated.id ? updated : p)))
}
onMove={(delta) => {
const moved = shift(products, i, delta);
void settle(moved, () => reorderProducts(moved.map((p) => p.id)));
}}
onDelete={() => {
if (!confirm(`Remove ${product.name} from the products page?`)) return;
void settle(
products.filter((p) => p.id !== product.id),
() => deleteProduct(product.id),
);
}}
/>
))}
</ul>
{products.length === 0 && (
<p className="mt-3 text-bakery-600">Nothing here yet add something above.</p>
)}
</section>
{/* A different page, and a different shape of editing — see the note at the top of Catering. */}
<hr className="border-bakery-200" />
<Catering onError={setError} />
</div>
)}
</div>
);
};
export default AdminPage;