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]>
The public site was a React SPA. It is now server-rendered Thymeleaf, and the goodie box and catering
tables added in the previous commit have a page of their own. The look is unchanged: the templates
carry the same Tailwind classes the components did, and every one of the 241 classes the five pages
use resolves in the compiled stylesheet.
WHAT WENT AWAY. PageMetaController — 148 lines whose only job was to splice per-page <title> and OG
tags into one shell with regular expressions, with a test that read the real index.html so that
reformatting it failed the build instead of silently breaking the rewriting. A page rendered on the
server writes its own head. Also react-router (no client-side routes left), motion, vite-plugin-svgr,
and the SPA fallback (platform.web.spa.enabled=false): with the site server-rendered, forwarding a
mistyped URL to /index.html would answer with a blank admin shell and a 200 instead of the site's own
404 page.
WHAT GOT BETTER ON THE WAY, none of it visible. The category filter is a ?category= link, so every
filtered view is a URL you can send someone and a crawler can reach all forty items instead of the
twelve the default filter showed. The contact form is a form post: the enquiry is recorded before
delivery is attempted, and a refused relay re-renders the page with what the visitor typed still in
the boxes. The mobile menu is a <details> — the React version needed four effects to close on
navigation, close on Escape, stop the page behind it scrolling, and unmount (a panel parked off-screen
still extends the scrollable area, which is how you used to be able to scroll sideways and find the
menu); a new document cannot inherit an open menu.
THE PUBLIC SITE SHIPS 5 KB OF JAVASCRIPT, and works without it. The product cards are scroll-snap
strips, so the photos swipe on a phone and scroll with a trackpad unaided; gallery.js adds the arrows
and the dots, and creates them itself rather than having the template render controls that would sit
there dead.
Tailwind still needs its compiler, so npm remains a BUILD tool: the CLI compiles the templates into
static/css/site.css at process-classes (so `spring-boot:run` gets it too), and frontend/ now builds
only that stylesheet and the admin. The brand tokens are one file both stylesheets import — the
alternative was the shop front and the screen that edits it drifting a shade apart. The stylesheet URL
carries ?v=<sha>, because one hand-written CSS file has no content hash and a deploy has to be able to
tell a browser that what it cached is stale.
The admin is still React and is untouched, apart from losing the router it no longer needs. It is an
editor, not content.
PlatformContractTest stopped inheriting platform-starter-test's contract and restates it. The shared
version asserts that an unknown path forwards to the SPA shell, which is no longer true here, and its
test methods are package-private so it cannot be overridden. The platform should decide that assertion
from platform.web.spa.enabled — noted in the file.
9 new tests (46 total): every page's real title and og:url, the catalogue and the catering tables in
the HTML rather than fetched afterwards, server-side filtering, the 404, and that a crafted ?about=
link cannot put words of its own choosing in front of a customer.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Two small things found reading it back: a note could be moved up but not down, and nothing exercised
`remove` against the real foreign keys — a delete that strands its children fails in Postgres rather
than in memory, and it's a button on the screen.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
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).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Run the workflow on pull_request too, so tests + build + Trivy must pass before main can be
merged (branch protection requires this check). Push the image only on a real push to main —
never from a PR. Also standardises the workflow across all apps (three had drifted).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
You built a self-service catalogue admin on feature/admin-and-ui-wins while I built a
competing one that had already merged and deployed. Both forked from c8cc8fe. Per your
call, your implementation is the one that stays.
Kept from main (files your branch didn't touch, so no conflict):
- the CI test gate (tests now run and block the image)
- motion 12.42.2
- the platform contract test
Took from your branch:
- split AdminProductController / AdminCategoryController + ProductPhotoService (server-side
webp via cwebp)
- a real category table (Category, V3__categories.sql) behind the product filters
- pages/Admin.tsx, with server-side /admin protection that redirects a browser to Authentik
and returns it to /admin afterward — cleaner than my client-side gate, and it avoids the
post-login-to-home issue my version had
Deleted my competing admin (AdminController, MeController, pages/admin/*, auth.tsx, and my
admin tests).
Grafted onto your gallery: swipe + arrow keys, which the deployed version had and yours
didn't. Added an AdminSecurityTest for your endpoints (admin closed, shop public, contact
CSRF) — the admin was otherwise untested, and CI now gates on tests.
Verified against a running container: /admin redirects a browser to Authentik (a bare 401
only for */* fetches, which is correct). 25 tests green.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
Post-login landed on the home page. Spring only remembers the pre-login location when it
BOUNCES you off a protected page, but every route here is public — the SPA sends you to the
identity provider itself — so nothing is saved and login defaults to '/'. AuthProvider now
stashes the current path in sessionStorage before the redirect and navigates back once /api/me
confirms the session. sessionStorage, not a query parameter: it survives the redirect chain,
stays in this tab, and cannot be pointed at another site. Sign-in is only ever triggered from
an /admin route, so that is exactly where the reader is returned.
Also merges the motion 12.42.2 bump, which had stayed open on its own PR.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
act_runner mounts it at the full repo path (/workspace/Owner/repo), not at /workspace, so
the previous lookup found nothing and the step refused to run — correctly, but for the
wrong reason.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
The image build runs 'mvn -DskipTests', and the workflow was only build -> Trivy -> push, so
no app test has ever run in CI. Only Trivy gated a merge. ContactControllerTest had been
broken since platform 0.1.6 and nothing noticed; the platform contract tests added in 0.1.9
were not running either, which defeated their purpose.
They cannot run inside 'docker build' — Testcontainers needs a Docker daemon and a build has
none. Maven runs as a sibling container instead, mounting the workspace volume act_runner
gave this job (discovered from our own container rather than guessed) and sharing the host
network so published test ports are reachable as localhost.
Fails loudly if the volume cannot be found, rather than quietly skipping the tests, which
would recreate exactly the problem this fixes.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
Admin
- /api/admin: products CRUD, the enquiry inbox, and presigned photo upload straight to the
bucket so images never pass through the app. Gated by platform.security.authenticated-paths
= /api/admin/**, so any signed-in Authentik user is staff — the alternative is a role model
a two-person bakery would never maintain.
- /api/me is deliberately PUBLIC. The SPA asks on every page load, and requiring a login
would bounce every anonymous visitor to Authentik just to read the menu.
- /admin screens: product list with edit and remove, an editor with drag-free photo
reordering and upload, and an enquiry inbox that flags anything the relay refused.
Gallery
- swipe on touch devices, which the react-awesome-slider it replaced had and this did not,
plus arrow keys and position dots — with swipe there is otherwise nothing to say a card
holds more than one photo. Vertical drags are ignored so page scrolling still works.
- @BatchSize on the photo collection: the products page loaded the whole catalogue and
Hibernate issued a query per product for its images, forty-odd round trips for a page
that needs two.
Three things the tests caught, none of which are obvious:
- Adding the storage starter broke every existing test. It activates on a default endpoint,
so an S3 client is built even in tests and dies on blank keys.
- MockMvc's webAppContextSetup leaves the security filter chain OUT, so the first version of
the security test passed 200s and proved the opposite of what it claimed. It needs
.apply(springSecurity()).
- Turning on the security starter turns on CSRF — for the PUBLIC contact form too, which
then 403s. The SPA now reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN, and there is a
test asserting the form is rejected without it.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
PlatformWebContract asserts what this app must do because it is on the platform: an /api
path matching no controller 404s rather than returning the SPA, health is UP, the liveness
and readiness probes exist, and a client-side route forwards to the shell. That first one
shipped broken to six live sites and was found by typing a URL by hand; now it fails a
build instead.
renovate.json is three lines pointing at austin/renovate-config. The rules were copied per
repo, so they matched only by luck and a change was six edits.
Platform 0.1.9.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
Renovate's replacement PR swapped framer-motion for its successor 'motion' in
package.json but left the imports, so the build could not resolve them. The React entry
point is motion/react.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
TS7 errors with 'Option baseUrl has been removed'. The paths mapping already points at
./src/*, which resolves relative to this file without it, and TS 5.9 accepts the same
config — so this lands safely ahead of the TypeScript 7 bump.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
- .container back in @layer components so Tailwind's px-4 still wins; unlayered it had
quietly widened the gutter on every page
- shadow-sm -> shadow-xs: Tailwind v4 renamed the scale, so the ported markup was giving
every white card a heavier shadow than the live site
- route changes jump to the top again instead of smooth-scrolling
- preload the wordmark font and the hero image
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
Replaces the Next.js app. Same site, same look; the parts that were decisions rather
than markup now live in Java.
- catalogue, curated order, category filter and image URLs move from a TypeScript array
into Postgres behind /api/products and /api/categories
- contact form uses the shared platform-starter-contact: validate, RECORD, send, then
fan out to n8n. Recording first means a relay outage costs a notification, not an enquiry
- PageMetaController rewrites title/description/OG per route, replacing what Next's SSR
gave crawlers and link-preview scrapers
- 50MB of photos leave the repo for the MinIO bucket, re-encoded to webp (14MB) with EXIF
(including phone GPS) stripped
- fixes a catalogue typo: 'Strawberry Pie' was category 'Pies', which no filter matched, so
it was unreachable unless browsing All
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
Keeps the direct SMTP email as the reliable delivery path; when CONTACT_HUB_URL
is set, also POSTs the enquiry to the n8n webhook so the hub sends the customer
auto-reply and (later) creates CRM/task records. Best-effort with a 4s timeout,
so a slow or down hub never blocks or fails the form.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- next.config: output 'standalone' for a self-contained server bundle
- Dockerfile: multi-stage Next.js build (node:22-alpine)
- .gitea/workflows/deploy.yml: build + push to the Gitea registry
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
- New sage/cream palette and stacked logo lockup (The Vine over Coffeehouse
+ Bakery) driven by currentColor; logo SVGs now colorable
- Reuse the logo component for the hero and section marks
- Rewrite site copy: real founding (Morissa Bennett, 2024), real story from
the product range, remove invented claims and AI phrasing
- Contact form now sends over SMTP via /api/contact (nodemailer) with real
send/error states, server-side validation, and reply-to the customer;
delivers to CONTACT_TO. Add .env.example and a test-email script
- Fix mobile: unmount the off-screen menu (killed sideways scroll), cap logo
width, responsive heroes and type
- Remove Firebase (config, tracked build output, placeholder pages, nix
firebase-tools) now that hosting has moved
- Delete dead code: LoyaltyCardForm, ProductModal, LoadingSpinner, card-flip
CSS, unused Playfair font
- Serve dev/start on port 2024