Archived
build-and-publish / build (pull_request) Successful in 2m8s
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]>
151 lines
9.0 KiB
Markdown
151 lines
9.0 KiB
Markdown
# The Vine Coffeehouse + Bakery — itsthevine.com
|
|
|
|
Site for The Vine, 215 E Main Street, Princeville, Illinois. Spring Boot rendering its own pages with
|
|
Thymeleaf, on [the Bennett platform](https://git.thebennett.net/austin/platform).
|
|
|
|
Previously a Next.js app on Cloudflare, then a React SPA on Spring, now server-rendered. The look has
|
|
not changed through any of it.
|
|
|
|
## Shape
|
|
|
|
| | |
|
|
|---|---|
|
|
| Backend | Spring Boot 4 / Java 25, `com.itsthevine.web` |
|
|
| Pages | Thymeleaf, `src/main/resources/templates` — **no JavaScript** except one 100-line file for the product-card arrows |
|
|
| Styling | Tailwind v4, compiled from the templates by the Tailwind CLI into `static/css/site.css` |
|
|
| Admin | the one React screen that is left, served at `/admin` only |
|
|
| Database | Postgres (`itsthevine` on the shared `app-db` cluster), Flyway |
|
|
| Photos | public MinIO bucket `itsthevine` — **not** in the repo or the image |
|
|
| Deploy | Gitea CI → image → Watchtower → Caddy |
|
|
|
|
### Why server-rendered
|
|
|
|
The pages are content: a menu, a story, opening hours, a price list. Rendering them in the browser meant
|
|
shipping a router and a component tree to show them, and it meant `PageMetaController` — a class whose
|
|
only job was to splice per-page `<title>` and OG tags into one shell with regular expressions, because a
|
|
crawler or a link-preview scraper got nothing useful otherwise. A page that is rendered on the server
|
|
writes its own head, so that whole mechanism is deleted rather than ported. The category filter is a
|
|
`?category=` link instead of a click handler, which also makes every filtered view a URL you can send
|
|
someone, and the contact form is a form post.
|
|
|
|
`platform.web.spa.enabled=false` follows from that: the platform's fallback forwards extension-less paths
|
|
to `/index.html`, which now holds nothing but the admin. `SiteController` maps `/admin` to it explicitly.
|
|
|
|
## What the server owns
|
|
|
|
Everything. The pages arrive complete.
|
|
|
|
- **`/api/products`**, **`/api/categories`** — the catalogue, its curated order, the category filter
|
|
and the absolute image URLs. This was a TypeScript array shipped to every visitor; it's now a table
|
|
(`V2__products.sql`) read through `ProductCatalog`.
|
|
- **`/catering`** — the goodie box and catering page. Each table is rendered twice from the same model
|
|
and CSS shows one: a real `<table>` on a wide screen, because that is what a price list is and a screen
|
|
reader then announces the size and the item together; stacked cards on a phone, because a four-column
|
|
price table there is either illegible or a sideways scroll, and this page is mostly read on phones.
|
|
- **`/api/catering`** — the same tables as JSON: the
|
|
columns, the prices already written the way they should be read, the entries under each column, and
|
|
the small print. These came from the bakery as a spreadsheet and are stored as one (`V4__catering.sql`,
|
|
read through `CateringMenu`) rather than as markup, because the prices move and the last line of that
|
|
spreadsheet says the tables are "mostly just an idea for people". `Money` is the only thing that
|
|
decides what a typed price means or how it prints. A table with no columns or no lines is left off the
|
|
public response — adding a table and filling it in are two separate acts in the admin, and the gap
|
|
between them shouldn't put a bare heading on the live page.
|
|
- **`/contact`** — the form posts here and gets a page back. It renders rather than redirects on failure,
|
|
so a refused relay comes back with what the visitor typed still in the boxes: they wrote it once, and
|
|
the failure is ours. `/api/contact` still exists and answers JSON; both go through `Enquiries`, so
|
|
there is one order of operations for taking an enquiry.
|
|
- **`/api/contact`** — validates, **records the enquiry**, emails it, then fans out to the n8n hub.
|
|
Recorded before sending on purpose: a relay outage costs a notification, not the enquiry. Undelivered
|
|
ones are `enquiry.delivered = false`. Validation and delivery come from `platform-starter-contact`,
|
|
shared with the other sites.
|
|
- **Per-page metadata** — each route states its own title and description in `SiteController`, next to
|
|
the handler that serves it, and `fragments/head.html` lays them out. `SiteControllerTest` asserts the
|
|
real `<title>` of every page.
|
|
|
|
## /admin
|
|
|
|
**The last React in the repo.** The public pages are server-rendered; this screen is a Vite/React app
|
|
because it is not content — it is an editor, and the instant-feedback editing (reorder that applies
|
|
before the network answers, a whole price table arranged on screen and saved in one go) is the point of
|
|
it. Everything under `frontend/` builds only this, plus the site's stylesheet.
|
|
|
|
The catalogue is editable from the site: add an item with a photo and a name, reorder it, rename or
|
|
reorder the category filters. Nothing there needs a deploy or a migration — which is the point, since
|
|
the person adding a cake is the person who baked it.
|
|
|
|
Photos are resized, stripped of EXIF, converted to webp and put in the bucket on upload
|
|
(`ProductPhotoService`, using `cwebp` from `libwebp-tools` — the pure-Java encoders either can't write
|
|
webp or ship glibc natives that don't run on Alpine).
|
|
|
|
The catering tables are editable there too, but a table at a time rather than a field at a time. That
|
|
isn't a different taste in interfaces: a column heading, its price and the entries beneath it only mean
|
|
anything together, so `CateringPackage#arrange` takes the whole table and refuses one whose lines and
|
|
columns disagree. Drop the middle column on its own and every remaining entry shifts one place left —
|
|
the Large box then advertises the Medium box's contents at the Large price, and nothing about the page
|
|
looks broken.
|
|
|
|
**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController`,
|
|
`AdminCategoryController` and `AdminCateringController` are `@ConditionalOnProperty` on it, so a deployment that forgets to configure
|
|
Authentik gets 404s rather than catalogue writes open to the internet. `/admin` and `/api/admin/**` are
|
|
both authenticated paths: a browser opening the page is sent to Authentik first, while `fetch` calls get
|
|
a bare 401 to handle.
|
|
|
|
Known gap: `StorageService` has no delete, so removing a product or a photo leaves the object in the
|
|
bucket. Harmless — nothing links to it — but it accumulates.
|
|
|
|
## Photos
|
|
|
|
Re-encoded to webp and uploaded to the bucket once (50 MB of originals → 14 MB), served with a
|
|
year-long cache. `site.assets.base-url` says where they live. The originals remain in this repo's
|
|
history. EXIF (including GPS from phone photos) is stripped by the re-encode.
|
|
|
|
## Local development
|
|
|
|
```bash
|
|
# the whole site (needs Postgres on :5432 with an itsthevine database)
|
|
mvn spring-boot:run # http://localhost:8080
|
|
|
|
# just the stylesheet, while editing templates — watches and recompiles
|
|
cd frontend && npm install && npx tailwindcss -i site.css -o ../target/classes/static/css/site.css --watch
|
|
|
|
# the admin screen, proxying /api to :8080
|
|
cd frontend && npm run dev # http://localhost:2024/admin
|
|
```
|
|
|
|
`mvn spring-boot:run` compiles the stylesheet on the way (the Tailwind step is bound to
|
|
`process-classes` for exactly that reason). `-DskipFrontend=true` skips both frontend steps for a fast
|
|
backend loop — the pages then render **unstyled** until you build the CSS once.
|
|
|
|
Templates are cached by default, so a template edit needs a restart; add
|
|
`spring.thymeleaf.cache=false` to a local run if you are editing markup.
|
|
|
|
Tests need Docker (Testcontainers):
|
|
|
|
```bash
|
|
mvn verify
|
|
```
|
|
|
|
## Configuration
|
|
|
|
| Variable | Purpose |
|
|
|---|---|
|
|
| `DB_URL` / `DB_USER` / `DB_PASSWORD` | Postgres |
|
|
| `SMTP_SERVER` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_TOKEN` | relay for the contact form |
|
|
| `CONTACT_TO` / `CONTACT_FROM` | enquiry recipient and envelope sender |
|
|
| `CONTACT_HUB_URL` | optional n8n webhook; best-effort, never blocks a submission |
|
|
| `SITE_BASE_URL` | absolute base for `og:url` |
|
|
| `site.assets.base-url` | photo bucket. Server-side only now — the browser is handed finished URLs |
|
|
| `GIT_SHA` | passed by the image build; becomes `?v=` on the stylesheet so a deploy invalidates the cached CSS |
|
|
| `SECURITY_MODE` | `OIDC` turns on Authentik login **and brings `/admin` into existence**. Unset = brochure site, no admin |
|
|
| `STORAGE_ENDPOINT` / `STORAGE_ACCESS_KEY` / `STORAGE_SECRET_KEY` / `STORAGE_BUCKET` | MinIO, for admin photo uploads. Blank endpoint leaves storage switched off |
|
|
|
|
With `SECURITY_MODE=OIDC` the app also needs the standard Spring OAuth2 client properties for the
|
|
Authentik application — `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_*` and
|
|
`..._PROVIDER_*_ISSUER_URI`. The starter configures the filter chain, not the identity provider.
|
|
|
|
A missing `CONTACT_TO` **stops the app from starting**. That is deliberate: `application.yaml` maps it
|
|
to `platform.contact.to`, and an unset variable leaves the property present-but-empty, which is enough
|
|
to activate the contact starter. Without the `@NotBlank` check in `platform-starter-contact` the site
|
|
would come up, show a working contact form, and mail every enquiry to nobody. Better to fail on deploy
|
|
than to lose a week of orders.
|