The admin is Thymeleaf too: no JavaScript framework left in the repo
build-and-publish / build (pull_request) Successful in 2m0s

The last React went with this. /admin and /admin/catering are pages of forms; every write is a POST and
a redirect back, so the back button and reload do what they look like they do, a double-tap cannot
repeat an upload, and there is no client-side state to lose — a reload is always the truth. The
/api/admin/** endpoints went too: they existed for the React screen, and their logic now lives in
Catalogue (extracted from the two deleted JSON controllers) and CateringMenu, which the pages call.

THE TABLE EDITOR IS THE INTERESTING PART, because a catering table cannot be edited a field at a time
— a column heading, its price and the entries beneath it only mean anything together. One form holds
the whole table and every button submits it; `name="do"` says which was pressed and its value carries
the position (`remove-column:2`). "Add a column" therefore arrives with every cell the editor has
typed, adds the column to what arrived plus an empty entry on every line, and re-renders. Nothing
typed is lost, and only Save writes — so a half-built table with a blank heading never reaches the
live page. A failed save comes back the same way, with the work still in the form and the reason above
it; a redirect would throw the work away and leave them guessing which cell the message was about.
Spring binds `lines[2].values[1]` into the right cell, which flat repeated parameters could not
promise.

Reordering moved to the server, where it always belonged: the browser used to compute the new order
and send the whole list back, and now "move this up" arrives as an action. Same for arranging photos —
one endpoint takes the key and -1/1/0 (earlier, later, remove), because those three buttons are the
same edit.

frontend/ became styles/: node, Tailwind and nothing else. It exists because Tailwind needs a
compiler and the alternative is a hand-written stylesheet; there is no bundler and no framework. The
admin's controls are @utility classes (v4 will only let you @apply a registered utility, and only a
utility can take the `file:` variant the photo pickers use) — the same buttons the React screen had,
from the same class strings it composed. Also fixed .gitignore, which still named frontend/: with
styles/ unlisted, `git add -A` staged 1,626 files of node_modules.

Verified against a running container, not only in tests: pressing "+ Column" returns the draft with an
unsaved cell intact, a new column and a matching new entry on the line, "Not saved yet" — and the live
page unchanged; Save then writes both columns with the price parsed from "48". Renaming and moving an
item land on the products page. Deleting a category that is in use is refused with the sentence naming
it. Removing the only photo of an item is refused, and that button is already disabled in the page.

51 tests (10 new): the form binding, the flash on success and on refusal, a structural button writing
nothing, and the whole admin surface closed to anonymous visitors. PlatformContractTest's routing
assertion now says what is true — an unknown path 404s, and so does /admin when no identity provider
is configured, because AdminController only exists under OIDC.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-07-26 16:47:10 -05:00
co-authored by Claude Opus 5
parent 61f3eb90ff
commit 54710019d2
34 changed files with 1625 additions and 3299 deletions
@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:replace="~{admin/layout :: page('The catalogue', ~{::content})}">
<body>
<div th:fragment="content">
<!--/* The filter buttons. Renaming one carries everything filed under it, which is why the rename is a
form of its own rather than an inline edit that might get half-submitted. */-->
<section class="card">
<h2 class="card-heading">Categories</h2>
<p class="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 class="mt-3 divide-y divide-bakery-100">
<li th:each="filter, f : ${filters}" class="flex flex-wrap items-center gap-2 py-2">
<div class="flex gap-1">
<form method="post" th:action="@{/admin/categories/{id}/move(id=${filter.id})}">
<input type="hidden" name="by" value="-1">
<button type="submit" class="btn-icon" th:disabled="${f.first}"
th:aria-label="|Move ${filter.name} up|">&uarr;</button>
</form>
<form method="post" th:action="@{/admin/categories/{id}/move(id=${filter.id})}">
<input type="hidden" name="by" value="1">
<button type="submit" class="btn-icon" th:disabled="${f.last}"
th:aria-label="|Move ${filter.name} down|">&darr;</button>
</form>
</div>
<form method="post" th:action="@{/admin/categories/{id}(id=${filter.id})}"
class="flex flex-1 min-w-60 items-center gap-2">
<label class="flex-1">
<span class="sr-only" th:text="|Name of the ${filter.name} category|">Name</span>
<input class="field" name="name" th:value="${filter.name}" required>
</label>
<button type="submit" class="btn-secondary">Rename</button>
</form>
<span class="text-sm text-bakery-500 whitespace-nowrap"
th:text="|${filter.used} item${filter.used == 1 ? '' : 's'}|">0 items</span>
<form method="post" th:action="@{/admin/categories/{id}/delete(id=${filter.id})}">
<button type="submit" class="btn-danger" th:disabled="${filter.used > 0}"
th:title="${filter.used > 0} ? 'Move its items somewhere else first' : 'Delete'"
th:aria-label="|Delete the ${filter.name} category|">Delete</button>
</form>
</li>
</ul>
<form method="post" action="/admin/categories" class="mt-3 flex gap-2">
<label class="flex-1">
<span class="sr-only">New category</span>
<input class="field" name="name" placeholder="New category" required>
</label>
<button type="submit" class="btn-primary">Add</button>
</form>
</section>
<!--/* Adding an item. enctype matters: without it the browser posts filenames instead of files. */-->
<section class="card">
<h2 class="card-heading">Add something new</h2>
<p class="mt-1 text-sm text-bakery-600">
New items go to the top of the products page. Photos are resized, stripped of their EXIF (including
the location your phone put in them) and converted on upload, so this can take a few seconds each.
</p>
<form method="post" action="/admin/items" enctype="multipart/form-data" class="mt-3 space-y-3">
<div class="grid gap-2 sm:grid-cols-[1fr_12rem]">
<label class="block">
<span class="sr-only">What is it?</span>
<input class="field" name="name" placeholder="What is it? e.g. Chocolate drip cake" required>
</label>
<label class="block">
<span class="sr-only">Category</span>
<select class="field" name="category" required>
<option th:each="filter : ${filters}" th:value="${filter.name}" th:text="${filter.name}">Cakes</option>
</select>
</label>
</div>
<div class="flex flex-wrap items-center gap-2">
<input type="file" name="photos" accept="image/*" multiple required
class="text-sm text-bakery-800 file:btn file:btn-secondary file:mr-3">
<button type="submit" class="btn-primary">Add to the page</button>
</div>
</form>
</section>
<!--/* The catalogue itself. One card per item, and every control on it is a form: there is no
client-side state here, so a reload is always the truth. */-->
<section>
<h2 class="card-heading" th:text="|On the page (${#lists.size(items)})|">On the page</h2>
<ul class="mt-3 space-y-3">
<li th:each="item, i : ${items}" class="card">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div class="flex sm:flex-col gap-1 sm:pt-1">
<form method="post" th:action="@{/admin/items/{id}/move(id=${item.id})}">
<input type="hidden" name="by" value="-1">
<button type="submit" class="btn-icon" th:disabled="${i.first}"
th:aria-label="|Move ${item.name} up|">&uarr;</button>
</form>
<form method="post" th:action="@{/admin/items/{id}/move(id=${item.id})}">
<input type="hidden" name="by" value="1">
<button type="submit" class="btn-icon" th:disabled="${i.last}"
th:aria-label="|Move ${item.name} down|">&darr;</button>
</form>
</div>
<div class="flex-1 min-w-0 space-y-3">
<form method="post" th:action="@{/admin/items/{id}(id=${item.id})}"
class="grid gap-2 sm:grid-cols-[1fr_12rem_auto]">
<label class="block">
<span class="sr-only">Name</span>
<input class="field" name="name" th:value="${item.name}" required>
</label>
<label class="block">
<span class="sr-only">Category</span>
<select class="field" name="category">
<!--/* An item can sit in a category nobody defined; don't silently retype it. */-->
<option th:if="${!#lists.contains(filters.![name], item.category)}"
th:value="${item.category}" th:text="${item.category}" selected>Uncategorised</option>
<option th:each="filter : ${filters}" th:value="${filter.name}" th:text="${filter.name}"
th:selected="${filter.name == item.category}">Cakes</option>
</select>
</label>
<button type="submit" class="btn-secondary">Save</button>
</form>
<!--/* Photos, in the order the products page shows them: the first is the one the card
leads with. Left/right rather than a drag target, which is far easier to hit on a
phone. */-->
<div class="flex flex-wrap gap-3">
<figure th:each="photo, p : ${item.photos}" class="w-28">
<img th:src="${photo.url}" alt="" loading="lazy"
class="w-28 h-28 rounded-md object-cover border border-bakery-200 bg-bakery-100">
<figcaption class="mt-1 flex items-center justify-between gap-1">
<div class="flex gap-1">
<form method="post" th:action="@{/admin/items/{id}/photos/arrange(id=${item.id})}">
<input type="hidden" name="key" th:value="${photo.key}">
<input type="hidden" name="move" value="-1">
<button type="submit" class="btn-icon" th:disabled="${p.first}"
aria-label="Move photo earlier">&larr;</button>
</form>
<form method="post" th:action="@{/admin/items/{id}/photos/arrange(id=${item.id})}">
<input type="hidden" name="key" th:value="${photo.key}">
<input type="hidden" name="move" value="1">
<button type="submit" class="btn-icon" th:disabled="${p.last}"
aria-label="Move photo later">&rarr;</button>
</form>
</div>
<form method="post" th:action="@{/admin/items/{id}/photos/arrange(id=${item.id})}">
<input type="hidden" name="key" th:value="${photo.key}">
<input type="hidden" name="move" value="0">
<!--/* The server refuses to leave an item with no photos; saying so up front beats an
error message. */-->
<button type="submit" class="btn-icon" th:disabled="${#lists.size(item.photos) == 1}"
th:title="${#lists.size(item.photos) == 1} ? 'An item needs at least one photo' : 'Remove photo'"
aria-label="Remove photo">&times;</button>
</form>
</figcaption>
</figure>
</div>
<div class="flex flex-wrap items-center gap-2">
<form method="post" th:action="@{/admin/items/{id}/photos(id=${item.id})}"
enctype="multipart/form-data" class="flex flex-wrap items-center gap-2">
<input type="file" name="photos" accept="image/*" multiple required
class="text-sm text-bakery-800 file:btn file:btn-secondary file:mr-3">
<button type="submit" class="btn-secondary">Add photos</button>
</form>
<form method="post" th:action="@{/admin/items/{id}/delete(id=${item.id})}" class="ml-auto">
<button type="submit" class="btn-danger"
th:aria-label="|Remove ${item.name} from the products page|">Delete</button>
</form>
</div>
</div>
</div>
</li>
</ul>
<p th:if="${#lists.isEmpty(items)}" class="mt-3 text-bakery-600">
Nothing here yet — add something above.
</p>
</section>
</div>
</body>
</html>
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:replace="~{admin/layout :: page('Goodie boxes & catering', ~{::content})}">
<body>
<div th:fragment="content">
<section>
<h2 class="card-heading">The price tables</h2>
<p class="mt-1 text-sm text-bakery-600">
In the order they appear on the page. Open one to change its columns, its prices or what's in it.
A table with no columns or no lines stays off the public page until it has both.
</p>
<ul class="mt-3 space-y-3">
<li th:each="table, t : ${menu.packages}" class="card flex flex-wrap items-center gap-3">
<div class="flex gap-1">
<form method="post" th:action="@{/admin/catering/tables/{id}/move(id=${table.id})}">
<input type="hidden" name="by" value="-1">
<button type="submit" class="btn-icon" th:disabled="${t.first}"
th:aria-label="|Move the ${table.name} table up|">&uarr;</button>
</form>
<form method="post" th:action="@{/admin/catering/tables/{id}/move(id=${table.id})}">
<input type="hidden" name="by" value="1">
<button type="submit" class="btn-icon" th:disabled="${t.last}"
th:aria-label="|Move the ${table.name} table down|">&darr;</button>
</form>
</div>
<div class="flex-1 min-w-60">
<a th:href="@{/admin/catering/tables/{id}(id=${table.id})}"
class="font-adbhashitha text-lg text-bakery-900 underline underline-offset-4"
th:text="${table.name}">Office</a>
<p class="text-sm text-bakery-600">
<span th:text="|${#lists.size(table.tiers)} column${#lists.size(table.tiers) == 1 ? '' : 's'}|">3 columns</span>,
<span th:text="|${#lists.size(table.rows)} line${#lists.size(table.rows) == 1 ? '' : 's'}|">3 lines</span>
<span th:if="${#lists.isEmpty(table.tiers) or #lists.isEmpty(table.rows)}"
class="text-bakery-700"> — not on the page yet</span>
</p>
</div>
<a th:href="@{/admin/catering/tables/{id}(id=${table.id})}" class="btn-secondary">Edit</a>
<form method="post" th:action="@{/admin/catering/tables/{id}/delete(id=${table.id})}">
<button type="submit" class="btn-danger"
th:aria-label="|Delete the ${table.name} table|">Delete</button>
</form>
</li>
</ul>
<p th:if="${#lists.isEmpty(menu.packages)}" class="mt-3 text-bakery-600">
No tables yet. The catering page will tell people to call instead until there is one.
</p>
<form method="post" action="/admin/catering/tables" class="mt-4 flex gap-2">
<label class="flex-1">
<span class="sr-only">New table</span>
<input class="field" name="name" placeholder="New table, e.g. Graduation parties" required>
</label>
<button type="submit" class="btn-primary">Add</button>
</form>
</section>
<!--/* The page's own terms, as opposed to the small print under one table. Replaced as a whole list:
deleting one is an omission, which is the same rule the tables follow. */-->
<section class="card">
<h2 class="card-heading">Under the whole page</h2>
<p class="mt-1 text-sm text-bakery-600">Terms that apply whichever table someone is reading.</p>
<form method="post" action="/admin/catering/notes" class="mt-3 space-y-2">
<label th:each="note : ${menu.notes}" class="block">
<span class="sr-only">Note</span>
<textarea class="field min-h-[3.25rem]" rows="2" name="notes" th:text="${note}"></textarea>
</label>
<!--/* An empty box is how you delete one: blank notes are dropped on save. */-->
<label class="block">
<span class="sr-only">Another note</span>
<textarea class="field min-h-[3.25rem]" rows="2" name="notes" placeholder="Add another note"></textarea>
</label>
<button type="submit" class="btn-primary">Save these notes</button>
<p class="text-sm text-bakery-600">Clearing a box and saving removes that note.</p>
</form>
</section>
</div>
</body>
</html>
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<!--/*
The admin's shell.
It deliberately doesn't wear the site's chrome: the public nav would offer an editor links away from
what they were doing, and the opening hours in the footer are noise on a screen whose whole job is the
catalogue. Same stylesheet, same brand.
Getting here at all means signing in — /admin/** is an authenticated path, so an unknown visitor is
sent to Authentik before any of this renders.
*/-->
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:fragment="page(title, content)">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<link rel="icon" href="/images/resources/logo_L.png">
<title th:text="|${title} · The Vine|">The Vine — admin</title>
<link rel="stylesheet" th:href="|/css/site.css?v=${build}|">
</head>
<body>
<div class="min-h-screen bg-bakery-50">
<div class="mx-auto max-w-5xl px-4 py-10 sm:px-6">
<header class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="font-lejour text-4xl text-bakery-700">The Vine</h1>
<nav class="mt-1 flex flex-wrap gap-4 text-sm">
<a href="/admin" class="text-bakery-700 underline underline-offset-4 hover:text-bakery-900">The catalogue</a>
<a href="/admin/catering" class="text-bakery-700 underline underline-offset-4 hover:text-bakery-900">Goodie boxes &amp; catering</a>
</nav>
</div>
<div class="flex items-center gap-2">
<a href="/products" class="btn-secondary">View the site</a>
<!--/* A real form post: the platform's logout expects one, and it also ends the Authentik
session — a link would leave you signed in at the identity provider and straight back in
on the next click. */-->
<form method="post" action="/logout">
<button type="submit" class="btn-secondary">Sign out</button>
</form>
</div>
</header>
<!--/* One place for the outcome of the last edit, whichever page posted it. */-->
<div th:if="${problem}" role="alert"
class="mt-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800"
th:text="${problem}">Something did not save.</div>
<div th:if="${done}" role="status"
class="mt-6 rounded-md border border-bakery-200 bg-white px-4 py-3 text-sm text-bakery-800"
th:text="${done}">Saved.</div>
<div class="mt-6 space-y-6">
<div th:replace="${content}"></div>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
th:replace="~{admin/layout :: page(${table.name} + ' table', ~{::content})}">
<body>
<div th:fragment="content">
<!--/*
One price table, in one form.
Every button in here submits this form. `name="do"` says which was pressed, and its value carries the
position it applies to (`remove-column:2`, `move-line:0:1`) — a button can only send its own name and
value, so that is where the argument goes.
Only "Save" writes anything. The structural buttons come back with the table you were looking at, plus
or minus a column or a line, and every cell you had typed still in it: adding a column adds an empty
entry to every line, and removing one takes its entries with it, so the grid stays square. That
alignment is the invariant CateringPackage#arrange refuses to break, and doing it on the server means
there is one implementation of it rather than one here and one in a browser.
*/-->
<form method="post" th:action="@{/admin/catering/tables/{id}(id=${tableId})}" th:object="${table}">
<div class="card">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<h2 class="card-heading">This table</h2>
<a href="/admin/catering" class="text-sm text-bakery-700 underline underline-offset-4">All tables</a>
</div>
<div class="mt-3 grid gap-2 sm:grid-cols-[14rem_1fr]">
<label class="block">
<span class="sr-only">Table name</span>
<input class="field" th:field="*{name}" placeholder="Weddings" required>
</label>
<label class="block">
<span class="sr-only">A line under the heading</span>
<input class="field" th:field="*{blurb}" placeholder="Optional — a line under the heading">
</label>
</div>
<!--/* Wide tables scroll here rather than making the page scroll sideways. */-->
<div class="mt-4 -mx-4 overflow-x-auto px-4">
<table class="w-full border-separate border-spacing-1">
<thead>
<tr>
<th scope="col" class="w-48 text-left text-sm font-medium text-bakery-600">What they get</th>
<th th:each="column, c : *{columns}" scope="col" class="min-w-44 align-top">
<input type="hidden" th:field="*{columns[__${c.index}__].id}">
<input class="field" th:field="*{columns[__${c.index}__].label}" placeholder="Small"
th:aria-label="|Heading for column ${c.count}|">
<input class="field mt-1" th:field="*{columns[__${c.index}__].price}"
placeholder="$24 — leave empty to ask" th:aria-label="|Price for column ${c.count}|">
<div class="mt-1 flex justify-center gap-1">
<button type="submit" name="do" th:value="|move-column:${c.index}:-1|" class="btn-icon"
th:disabled="${c.first}" aria-label="Move this column left">&larr;</button>
<button type="submit" name="do" th:value="|move-column:${c.index}:1|" class="btn-icon"
th:disabled="${c.last}" aria-label="Move this column right">&rarr;</button>
<button type="submit" name="do" th:value="|remove-column:${c.index}|" class="btn-icon"
title="Removes this column and its entries on every line"
aria-label="Remove this column">&times;</button>
</div>
</th>
<th scope="col" class="w-28 align-top">
<button type="submit" name="do" value="add-column" class="btn-secondary">+ Column</button>
</th>
</tr>
</thead>
<tbody>
<tr th:each="line, l : *{lines}">
<th scope="row" class="text-left align-top">
<input type="hidden" th:field="*{lines[__${l.index}__].id}">
<input class="field" th:field="*{lines[__${l.index}__].label}" placeholder="Mini muffins"
th:aria-label="|Name of line ${l.count}|">
</th>
<td th:each="value, v : ${line.values}" class="align-top">
<input class="field" th:field="*{lines[__${l.index}__].values[__${v.index}__]}" placeholder="—"
th:aria-label="|Line ${l.count}, column ${v.count}|">
</td>
<td class="align-top">
<div class="flex gap-1">
<button type="submit" name="do" th:value="|move-line:${l.index}:-1|" class="btn-icon"
th:disabled="${l.first}" aria-label="Move this line up">&uarr;</button>
<button type="submit" name="do" th:value="|move-line:${l.index}:1|" class="btn-icon"
th:disabled="${l.last}" aria-label="Move this line down">&darr;</button>
<button type="submit" name="do" th:value="|remove-line:${l.index}|" class="btn-icon"
aria-label="Remove this line">&times;</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<button type="submit" name="do" value="add-line" class="btn-secondary mt-1">+ Line</button>
<div class="mt-4">
<p class="text-sm text-bakery-600">
Small print under this table — minimums, what can't be mixed, how delivery is charged.
</p>
<div class="mt-2 space-y-2">
<div th:each="note, n : *{notes}" class="flex items-start gap-2">
<label class="flex-1">
<span class="sr-only" th:text="|Note ${n.count}|">Note</span>
<textarea class="field min-h-[3.25rem]" rows="2" th:field="*{notes[__${n.index}__]}"></textarea>
</label>
<button type="submit" name="do" th:value="|remove-note:${n.index}|" class="btn-icon mt-1"
aria-label="Remove this note">&times;</button>
</div>
</div>
<button type="submit" name="do" value="add-note" class="btn-secondary mt-2">+ Note</button>
</div>
<div class="mt-4 flex flex-wrap items-center gap-2 border-t border-bakery-100 pt-3">
<button type="submit" name="do" value="save" class="btn-primary">Save this table</button>
<a th:href="@{/admin/catering/tables/{id}(id=${tableId})}" class="btn-secondary">Start again</a>
<span th:if="${unsaved}" class="text-sm text-bakery-700">
Not saved yet — press Save when the table looks right.
</span>
</div>
</div>
</form>
</div>
</body>
</html>