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; }) => (
{product.images.map((url, i) => (
))}
); /** 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(null); return ( <> { const files = Array.from(e.target.files ?? []); e.target.value = ''; if (files.length) onPick(files); }} /> ); }; // --- 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) => { setBusy(true); try { onChange(await work()); } catch (e) { onError(e instanceof Error ? e.message : 'That did not save.'); } finally { setBusy(false); } }; return (
  • run(() => arrangePhotos(product.id, keys))} />
    run(() => addPhotos(product.id, files))} /> {dirty && ( <> )}
    {busy &&

    Working…

    }
  • ); }; // --- 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([]); 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 (

    Add something new

    New items go to the top of the products page.

    setName(e.target.value)} placeholder="What is it? e.g. Chocolate drip cake" />
    {files.length > 0 && (
    {files.map((file, i) => (
    URL.revokeObjectURL(e.currentTarget.src)} />
    ))}
    )}
    setFiles((current) => [...current, ...picked])} /> {busy && Photos can take a few seconds each.}
    ); }; // --- 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(null); const [draft, setDraft] = useState(''); const [busy, setBusy] = useState(false); const guard = async (work: () => Promise, 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 (

    Categories

    These are the filter buttons on the products page, in this order. Renaming one moves everything filed under it too.

      {categories.map((category, i) => (
    • {editing === category.id ? ( <> setDraft(e.target.value)} onKeyDown={(e) => e.key === 'Escape' && setEditing(null)} /> ) : ( <> {category.name} {category.used} item{category.used === 1 ? '' : 's'} )}
    • ))}
    setFresh(e.target.value)} placeholder="New category" onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && guard(async () => { await createCategory(fresh.trim()); setFresh(''); })} />
    ); }; // --- the page --------------------------------------------------------------- const AdminPage = () => { const [products, setProducts] = useState(null); const [categories, setCategories] = useState([]); 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) => { const before = products ?? []; setProducts(optimistic); try { await work(); } catch (e) { setProducts(before); setError(e instanceof Error ? e.message : 'That did not save.'); } }; return (

    The Vine

    Everything on the products and catering pages lives here.

    View the page {/* A real form post: the platform's logout expects one, and it also ends the Authentik session. */}
    {error && (
    {error}
    )} {products === null ? (

    Loading…

    ) : (
    void load()} /> setProducts([product, ...products])} />

    On the page ({products.length})

      {products.map((product, i) => ( 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), ); }} /> ))}
    {products.length === 0 && (

    Nothing here yet — add something above.

    )}
    {/* A different page, and a different shape of editing — see the note at the top of Catering. */}
    )}
    ); }; export default AdminPage;