Archived
The admin is Thymeleaf too: no JavaScript framework left in the repo
build-and-publish / build (pull_request) Successful in 2m0s
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:
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
target/
|
target/
|
||||||
frontend/node_modules/
|
# The Tailwind CLI's dependencies. The compiled stylesheet needs no rule of its own: it is written
|
||||||
frontend/dist/
|
# straight into target/classes/static/css, which is already ignored above.
|
||||||
|
styles/node_modules/
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
Site for The Vine, 215 E Main Street, Princeville, Illinois. Spring Boot rendering its own pages with
|
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).
|
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
|
Previously a Next.js app on Cloudflare, then a React SPA on Spring, now server-rendered end to end —
|
||||||
not changed through any of it.
|
there is no JavaScript framework in this repo. The look has not changed through any of it.
|
||||||
|
|
||||||
## Shape
|
## Shape
|
||||||
|
|
||||||
@@ -12,8 +12,8 @@ not changed through any of it.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| Backend | Spring Boot 4 / Java 25, `com.itsthevine.web` |
|
| 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 |
|
| 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` |
|
| Styling | Tailwind v4, compiled from the templates by the Tailwind CLI into `static/css/site.css`. `styles/` is the whole asset pipeline |
|
||||||
| Admin | the one React screen that is left, served at `/admin` only |
|
| Admin | Thymeleaf forms at `/admin`, behind Authentik |
|
||||||
| Database | Postgres (`itsthevine` on the shared `app-db` cluster), Flyway |
|
| Database | Postgres (`itsthevine` on the shared `app-db` cluster), Flyway |
|
||||||
| Photos | public MinIO bucket `itsthevine` — **not** in the repo or the image |
|
| Photos | public MinIO bucket `itsthevine` — **not** in the repo or the image |
|
||||||
| Deploy | Gitea CI → image → Watchtower → Caddy |
|
| Deploy | Gitea CI → image → Watchtower → Caddy |
|
||||||
@@ -29,7 +29,8 @@ writes its own head, so that whole mechanism is deleted rather than ported. The
|
|||||||
someone, and the contact form is a form post.
|
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
|
`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.
|
to `/index.html` so a React SPA can own routing, and there is no SPA here — leaving it on would answer a
|
||||||
|
mistyped URL with a blank page and a 200 instead of the site's own 404.
|
||||||
|
|
||||||
## What the server owns
|
## What the server owns
|
||||||
|
|
||||||
@@ -64,10 +65,10 @@ Everything. The pages arrive complete.
|
|||||||
|
|
||||||
## /admin
|
## /admin
|
||||||
|
|
||||||
**The last React in the repo.** The public pages are server-rendered; this screen is a Vite/React app
|
Forms and redirects. Every write is a POST followed by a redirect back to the page, so the back button
|
||||||
because it is not content — it is an editor, and the instant-feedback editing (reorder that applies
|
and reload do what they look like they do, a double-tap can't repeat an upload, and there is no
|
||||||
before the network answers, a whole price table arranged on screen and saved in one go) is the point of
|
client-side state to lose — a reload is always the truth. Two screens: `/admin` is the catalogue,
|
||||||
it. Everything under `frontend/` builds only this, plus the site's stylesheet.
|
`/admin/catering` lists the price tables and `/admin/catering/tables/{id}` edits one.
|
||||||
|
|
||||||
The catalogue is editable from the site: add an item with a photo and a name, reorder it, rename or
|
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
|
reorder the category filters. Nothing there needs a deploy or a migration — which is the point, since
|
||||||
@@ -77,18 +78,31 @@ Photos are resized, stripped of EXIF, converted to webp and put in the bucket on
|
|||||||
(`ProductPhotoService`, using `cwebp` from `libwebp-tools` — the pure-Java encoders either can't write
|
(`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).
|
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
|
The catering tables are edited a table at a time rather than a field at a time. That isn't a taste in
|
||||||
isn't a different taste in interfaces: a column heading, its price and the entries beneath it only mean
|
interfaces: a column heading, its price and the entries beneath it only mean anything together, so
|
||||||
anything together, so `CateringPackage#arrange` takes the whole table and refuses one whose lines and
|
`CateringPackage#arrange` takes the whole table and refuses one whose lines and columns disagree. Drop
|
||||||
columns disagree. Drop the middle column on its own and every remaining entry shifts one place left —
|
the middle column on its own and every remaining entry shifts one place left — the Large box then
|
||||||
the Large box then advertises the Medium box's contents at the Large price, and nothing about the page
|
advertises the Medium box's contents at the Large price, and nothing about the page looks broken.
|
||||||
looks broken.
|
|
||||||
|
|
||||||
**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController`,
|
**How that works without JavaScript.** One form holds the whole table and every button in it submits
|
||||||
`AdminCategoryController` and `AdminCateringController` are `@ConditionalOnProperty` on it, so a deployment that forgets to configure
|
that form; `name="do"` says which was pressed and its value carries the position it applies to
|
||||||
Authentik gets 404s rather than catalogue writes open to the internet. `/admin` and `/api/admin/**` are
|
(`remove-column:2`). So "add a column" arrives with every cell the editor has typed, adds the column to
|
||||||
both authenticated paths: a browser opening the page is sent to Authentik first, while `fetch` calls get
|
what arrived — plus an empty entry on every line — and re-renders. Nothing typed is lost, and **only
|
||||||
a bare 401 to handle.
|
Save writes**: a half-built table with a blank column heading never reaches the live page, and the
|
||||||
|
aggregate would refuse it anyway. A failed save comes back the same way, with the work still in the
|
||||||
|
form and the reason above it, because a redirect would throw the work away and leave the editor guessing
|
||||||
|
which cell the message was about.
|
||||||
|
|
||||||
|
**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminController` 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/**` is an authenticated path, so a browser opening it is
|
||||||
|
sent to Authentik and comes back signed in. There is no JSON admin any more: `AdminProductController`,
|
||||||
|
`AdminCategoryController` and the old `/api/admin/**` endpoints existed for the React screen and went
|
||||||
|
with it. Their logic lives in `Catalogue` and `CateringMenu`, which the pages call.
|
||||||
|
|
||||||
|
**Testing a protected page needs `Accept: text/html`.** curl and MockMvc both send `*/*`, which the
|
||||||
|
platform answers with a bare 401; only a request that prefers HTML gets the 302 to Authentik. Asserting
|
||||||
|
the 401 and calling the page broken is a mistake worth not making twice.
|
||||||
|
|
||||||
Known gap: `StorageService` has no delete, so removing a product or a photo leaves the object in the
|
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.
|
bucket. Harmless — nothing links to it — but it accumulates.
|
||||||
@@ -102,16 +116,18 @@ history. EXIF (including GPS from phone photos) is stripped by the re-encode.
|
|||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# the whole site (needs Postgres on :5432 with an itsthevine database)
|
# the whole site, admin included (needs Postgres on :5432 with an itsthevine database)
|
||||||
mvn spring-boot:run # http://localhost:8080
|
mvn spring-boot:run # http://localhost:8080
|
||||||
|
|
||||||
# just the stylesheet, while editing templates — watches and recompiles
|
# 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
|
cd styles && npm install && npm run watch
|
||||||
|
|
||||||
# the admin screen, proxying /api to :8080
|
|
||||||
cd frontend && npm run dev # http://localhost:2024/admin
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`/admin` only exists when `SECURITY_MODE=OIDC`, so a plain local run has the site and no admin. To work
|
||||||
|
on the admin without an identity provider, run with `SECURITY_MODE=OIDC`, dummy
|
||||||
|
`spring.security.oauth2.client.*` values (see `AdminPagesTest` for a set that starts without touching
|
||||||
|
the network) and `platform.security.authenticated-paths=/nothing/**` so nothing asks you to sign in.
|
||||||
|
|
||||||
`mvn spring-boot:run` compiles the stylesheet on the way (the Tailwind step is bound to
|
`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
|
`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.
|
backend loop — the pages then render **unstyled** until you build the CSS once.
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="icon" media="(prefers-color-scheme: light)" href="/images/resources/logo_L.png">
|
|
||||||
<link rel="icon" media="(prefers-color-scheme: dark)" href="/images/resources/logo_dark.png">
|
|
||||||
<!-- The shell for /admin, and nothing else. The public pages are server-rendered Thymeleaf now, so
|
|
||||||
this file no longer carries metadata for crawlers, and PageMetaController — which used to rewrite
|
|
||||||
it per route with regular expressions — is gone. -->
|
|
||||||
<meta name="robots" content="noindex">
|
|
||||||
<title>The Vine — admin</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "itsthevine-frontend",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.1.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc --noEmit && vite build",
|
|
||||||
"build:css": "tailwindcss -i site.css -o ../target/classes/static/css/site.css --minify",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"react": "^19.2.7",
|
|
||||||
"react-dom": "^19.2.7"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/cli": "4.3.3",
|
|
||||||
"@tailwindcss/vite": "4.3.3",
|
|
||||||
"@types/node": "^26.1.0",
|
|
||||||
"@types/react": "^19.2.17",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
|
||||||
"tailwindcss": "4.3.3",
|
|
||||||
"typescript": "^7.0.0",
|
|
||||||
"vite": "^8.1.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/*
|
|
||||||
* The stylesheet for the server-rendered site.
|
|
||||||
*
|
|
||||||
* Compiled by the Tailwind CLI (`npm run build:css`) straight into target/classes/static/css: it is a
|
|
||||||
* source file, not a resource, and the generated stylesheet belongs in the build output rather than in
|
|
||||||
* src/main/resources next to it.
|
|
||||||
*
|
|
||||||
* It lives in this directory, beside the admin's stylesheet, because Tailwind resolves `@import
|
|
||||||
* "tailwindcss"` by walking up from the CSS file looking for node_modules — and node_modules is here.
|
|
||||||
* So this directory is the whole asset pipeline: one Tailwind, two stylesheets, one of them for pages
|
|
||||||
* that contain no JavaScript at all.
|
|
||||||
*
|
|
||||||
* @source points Tailwind at the templates, and at gallery.js — the product-card arrows are created in
|
|
||||||
* script, so their classes are only written down there. A utility exists in the output only if Tailwind
|
|
||||||
* saw it in one of these files, which is why a class name must never be assembled from pieces at
|
|
||||||
* runtime.
|
|
||||||
*/
|
|
||||||
@import "tailwindcss";
|
|
||||||
@import "./src/tokens.css";
|
|
||||||
|
|
||||||
@source "../src/main/resources/templates";
|
|
||||||
@source "../src/main/resources/static/js";
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import AdminPage from '@/pages/Admin';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What's left of the React app: the admin screen, and nothing else.
|
|
||||||
*
|
|
||||||
* The shop front is server-rendered Thymeleaf now, so there are no client-side routes to route
|
|
||||||
* between — react-router went with the pages it used to switch. Spring serves this shell at /admin and
|
|
||||||
* only at /admin; every other URL is a page in src/main/resources/templates.
|
|
||||||
*
|
|
||||||
* The admin sits outside the public chrome deliberately: the nav would offer a signed-in editor links
|
|
||||||
* away from unsaved work, and the opening hours in the footer are noise on a screen whose whole job is
|
|
||||||
* the catalogue.
|
|
||||||
*/
|
|
||||||
const App = () => (
|
|
||||||
<div className="min-h-screen bg-bakery-50">
|
|
||||||
<AdminPage />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,579 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
addCateringTable,
|
|
||||||
adminCatering,
|
|
||||||
deleteCateringTable,
|
|
||||||
reorderCateringTables,
|
|
||||||
saveCateringNotes,
|
|
||||||
saveCateringTable,
|
|
||||||
type CateringTable,
|
|
||||||
} from '@/lib/api';
|
|
||||||
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 goodie box and catering price tables, editable by the person who quotes them.
|
|
||||||
*
|
|
||||||
* A table is edited as a table and saved in one go, unlike the catalogue next door where every change
|
|
||||||
* saves as you make it. That's not a different taste in interfaces: a column heading, its price and
|
|
||||||
* the entries beneath it only mean anything together, so they have to be moved, added and removed
|
|
||||||
* together. Adding a column here adds an empty entry to every line, and removing one takes its
|
|
||||||
* entries with it — the server refuses any table whose lines and columns disagree, because the
|
|
||||||
* alternative is the Large box quietly advertising the Medium box's contents at the Large price.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// --- what's on screen -------------------------------------------------------
|
|
||||||
|
|
||||||
type TierDraft = { id: number | null; label: string; price: string };
|
|
||||||
type RowDraft = { id: number | null; label: string; values: string[] };
|
|
||||||
type Draft = { name: string; blurb: string; tiers: TierDraft[]; rows: RowDraft[]; notes: string[] };
|
|
||||||
|
|
||||||
const draftOf = (table: CateringTable): Draft => ({
|
|
||||||
name: table.name,
|
|
||||||
blurb: table.blurb ?? '',
|
|
||||||
// The price arrives written out ("$24"); it goes back as whatever the editor leaves in the box, and
|
|
||||||
// the server decides what that's worth.
|
|
||||||
tiers: table.tiers.map((tier) => ({ id: tier.id, label: tier.label, price: tier.price ?? '' })),
|
|
||||||
rows: table.rows.map((row) => ({ id: row.id, label: row.label, values: [...row.values] })),
|
|
||||||
notes: [...table.notes],
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Notes are edited as a list; deleting one is an omission, exactly as the server expects. */
|
|
||||||
const Notes = ({
|
|
||||||
notes,
|
|
||||||
hint,
|
|
||||||
disabled,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
notes: string[];
|
|
||||||
hint: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
onChange: (notes: string[]) => void;
|
|
||||||
}) => (
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-bakery-600">{hint}</p>
|
|
||||||
<ul className="mt-2 space-y-2">
|
|
||||||
{notes.map((note, i) => (
|
|
||||||
<li key={i} className="flex items-start gap-2">
|
|
||||||
<textarea
|
|
||||||
className={`${field} min-h-[3.25rem]`}
|
|
||||||
rows={2}
|
|
||||||
value={note}
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(e) => onChange(notes.map((n, at) => (at === i ? e.target.value : n)))}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-1 pt-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={disabled || i === 0}
|
|
||||||
onClick={() => onChange(shift(notes, i, -1))}
|
|
||||||
aria-label="Move note up"
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_UP} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={disabled || i === notes.length - 1}
|
|
||||||
onClick={() => onChange(shift(notes, i, 1))}
|
|
||||||
aria-label="Move note down"
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_DOWN} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onChange(notes.filter((_, at) => at !== i))}
|
|
||||||
aria-label="Remove note"
|
|
||||||
>
|
|
||||||
<Icon d={TRASH} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`${secondary} mt-2`}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onChange([...notes, ''])}
|
|
||||||
>
|
|
||||||
<Icon d={PLUS} />
|
|
||||||
Add a note
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- one table --------------------------------------------------------------
|
|
||||||
|
|
||||||
const TableCard = ({
|
|
||||||
table,
|
|
||||||
first,
|
|
||||||
last,
|
|
||||||
onSaved,
|
|
||||||
onMove,
|
|
||||||
onDelete,
|
|
||||||
onError,
|
|
||||||
}: {
|
|
||||||
table: CateringTable;
|
|
||||||
first: boolean;
|
|
||||||
last: boolean;
|
|
||||||
onSaved: (saved: CateringTable) => void;
|
|
||||||
onMove: (delta: number) => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onError: (message: string) => void;
|
|
||||||
}) => {
|
|
||||||
const [draft, setDraft] = useState<Draft>(() => draftOf(table));
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const stored = draftOf(table);
|
|
||||||
const dirty = JSON.stringify(draft) !== JSON.stringify(stored);
|
|
||||||
|
|
||||||
// A reorder re-renders this card with a fresh copy from the server; the boxes should follow along
|
|
||||||
// unless they're being edited.
|
|
||||||
const [synced, setSynced] = useState(table);
|
|
||||||
if (synced !== table) {
|
|
||||||
setSynced(table);
|
|
||||||
if (!dirty) setDraft(draftOf(table));
|
|
||||||
}
|
|
||||||
|
|
||||||
const edit = (change: Partial<Draft>) => setDraft({ ...draft, ...change });
|
|
||||||
|
|
||||||
// Columns. Every one of these keeps the lines in step — that is the whole job of this screen.
|
|
||||||
const addColumn = () =>
|
|
||||||
edit({
|
|
||||||
tiers: [...draft.tiers, { id: null, label: '', price: '' }],
|
|
||||||
rows: draft.rows.map((row) => ({ ...row, values: [...row.values, ''] })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const removeColumn = (column: number) =>
|
|
||||||
edit({
|
|
||||||
tiers: draft.tiers.filter((_, at) => at !== column),
|
|
||||||
rows: draft.rows.map((row) => ({ ...row, values: row.values.filter((_, at) => at !== column) })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const moveColumn = (column: number, delta: number) =>
|
|
||||||
edit({
|
|
||||||
tiers: shift(draft.tiers, column, delta),
|
|
||||||
rows: draft.rows.map((row) => ({ ...row, values: shift(row.values, column, delta) })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const setColumn = (column: number, change: Partial<TierDraft>) =>
|
|
||||||
edit({ tiers: draft.tiers.map((tier, at) => (at === column ? { ...tier, ...change } : tier)) });
|
|
||||||
|
|
||||||
// Lines.
|
|
||||||
const addLine = () =>
|
|
||||||
edit({ rows: [...draft.rows, { id: null, label: '', values: draft.tiers.map(() => '') }] });
|
|
||||||
|
|
||||||
const setLine = (line: number, change: Partial<RowDraft>) =>
|
|
||||||
edit({ rows: draft.rows.map((row, at) => (at === line ? { ...row, ...change } : row)) });
|
|
||||||
|
|
||||||
const setCell = (line: number, column: number, value: string) =>
|
|
||||||
setLine(line, {
|
|
||||||
values: draft.rows[line].values.map((entry, at) => (at === column ? value : entry)),
|
|
||||||
});
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
onSaved(
|
|
||||||
await saveCateringTable(table.id, {
|
|
||||||
name: draft.name.trim(),
|
|
||||||
blurb: draft.blurb.trim() || null,
|
|
||||||
tiers: draft.tiers.map((tier) => ({ id: tier.id, label: tier.label.trim(), price: tier.price.trim() })),
|
|
||||||
rows: draft.rows.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
label: row.label.trim(),
|
|
||||||
values: row.values.map((entry) => entry.trim()),
|
|
||||||
})),
|
|
||||||
notes: draft.notes,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
onError(e instanceof Error ? e.message : 'That did not save.');
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const columns = draft.tiers.length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
|
|
||||||
<div className="flex flex-wrap items-start gap-2">
|
|
||||||
<div className="flex gap-1 pt-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={first}
|
|
||||||
onClick={() => onMove(-1)}
|
|
||||||
aria-label={`Move the ${table.name} table up`}
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_UP} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={last}
|
|
||||||
onClick={() => onMove(1)}
|
|
||||||
aria-label={`Move the ${table.name} table down`}
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_DOWN} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="grid flex-1 gap-2 sm:grid-cols-[14rem_1fr]">
|
|
||||||
<label className="block">
|
|
||||||
<span className="sr-only">Table name</span>
|
|
||||||
<input
|
|
||||||
className={field}
|
|
||||||
value={draft.name}
|
|
||||||
onChange={(e) => edit({ name: e.target.value })}
|
|
||||||
placeholder="Weddings"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className="sr-only">A line under the heading</span>
|
|
||||||
<input
|
|
||||||
className={field}
|
|
||||||
value={draft.blurb}
|
|
||||||
onChange={(e) => edit({ blurb: e.target.value })}
|
|
||||||
placeholder="Optional — a line under the heading"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Wide tables scroll here rather than making the page scroll sideways. */}
|
|
||||||
<div className="mt-4 -mx-4 overflow-x-auto px-4">
|
|
||||||
<table className="w-full border-separate border-spacing-1">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col" className="w-48 text-left text-sm font-medium text-bakery-600">
|
|
||||||
What they get
|
|
||||||
</th>
|
|
||||||
{draft.tiers.map((tier, column) => (
|
|
||||||
<th key={tier.id ?? `new-${column}`} scope="col" className="min-w-44 align-top">
|
|
||||||
<input
|
|
||||||
className={field}
|
|
||||||
value={tier.label}
|
|
||||||
onChange={(e) => setColumn(column, { label: e.target.value })}
|
|
||||||
placeholder="Small"
|
|
||||||
aria-label={`Heading for column ${column + 1}`}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className={`${field} mt-1`}
|
|
||||||
value={tier.price}
|
|
||||||
onChange={(e) => setColumn(column, { price: e.target.value })}
|
|
||||||
placeholder="$24 — leave empty to ask"
|
|
||||||
aria-label={`Price for column ${column + 1}`}
|
|
||||||
/>
|
|
||||||
<div className="mt-1 flex justify-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={column === 0}
|
|
||||||
onClick={() => moveColumn(column, -1)}
|
|
||||||
aria-label="Move this column left"
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_LEFT} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={column === columns - 1}
|
|
||||||
onClick={() => moveColumn(column, 1)}
|
|
||||||
aria-label="Move this column right"
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_RIGHT} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
onClick={() => removeColumn(column)}
|
|
||||||
aria-label="Remove this column"
|
|
||||||
title="Removes this column and its entries on every line"
|
|
||||||
>
|
|
||||||
<Icon d={TRASH} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
<th scope="col" className="w-32 align-top">
|
|
||||||
<button type="button" className={secondary} onClick={addColumn}>
|
|
||||||
<Icon d={PLUS} />
|
|
||||||
Column
|
|
||||||
</button>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{draft.rows.map((row, line) => (
|
|
||||||
<tr key={row.id ?? `new-${line}`}>
|
|
||||||
<th scope="row" className="text-left align-top">
|
|
||||||
<input
|
|
||||||
className={field}
|
|
||||||
value={row.label}
|
|
||||||
onChange={(e) => setLine(line, { label: e.target.value })}
|
|
||||||
placeholder="Mini muffins"
|
|
||||||
aria-label={`Name of line ${line + 1}`}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
{row.values.map((entry, column) => (
|
|
||||||
<td key={column} className="align-top">
|
|
||||||
<input
|
|
||||||
className={field}
|
|
||||||
value={entry}
|
|
||||||
onChange={(e) => setCell(line, column, e.target.value)}
|
|
||||||
placeholder="—"
|
|
||||||
aria-label={`${row.label || `Line ${line + 1}`}, ${
|
|
||||||
draft.tiers[column]?.label || `column ${column + 1}`
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td className="align-top">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={line === 0}
|
|
||||||
onClick={() => edit({ rows: shift(draft.rows, line, -1) })}
|
|
||||||
aria-label="Move this line up"
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_UP} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
disabled={line === draft.rows.length - 1}
|
|
||||||
onClick={() => edit({ rows: shift(draft.rows, line, 1) })}
|
|
||||||
aria-label="Move this line down"
|
|
||||||
>
|
|
||||||
<Icon d={ARROW_DOWN} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={iconButton}
|
|
||||||
onClick={() => edit({ rows: draft.rows.filter((_, at) => at !== line) })}
|
|
||||||
aria-label="Remove this line"
|
|
||||||
>
|
|
||||||
<Icon d={TRASH} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" className={`${secondary} mt-1`} onClick={addLine}>
|
|
||||||
<Icon d={PLUS} />
|
|
||||||
Line
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<Notes
|
|
||||||
notes={draft.notes}
|
|
||||||
hint="Small print under this table — minimums, what can't be mixed, how delivery is charged."
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(notes) => edit({ notes })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-bakery-100 pt-3">
|
|
||||||
<button type="button" className={primary} disabled={busy || !dirty} onClick={() => void save()}>
|
|
||||||
<Icon d={CHECK} />
|
|
||||||
{busy ? 'Saving…' : 'Save this table'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={secondary}
|
|
||||||
disabled={busy || !dirty}
|
|
||||||
onClick={() => setDraft(draftOf(table))}
|
|
||||||
>
|
|
||||||
<Icon d={X} />
|
|
||||||
Undo my changes
|
|
||||||
</button>
|
|
||||||
{dirty && <span className="text-sm text-bakery-600">Not saved yet.</span>}
|
|
||||||
{(columns === 0 || draft.rows.length === 0) && !dirty && (
|
|
||||||
<span className="text-sm text-bakery-600">
|
|
||||||
Needs a column and a line before it shows on the page.
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button type="button" className={`${danger} ml-auto`} disabled={busy} onClick={onDelete}>
|
|
||||||
<Icon d={TRASH} />
|
|
||||||
Delete table
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- the section ------------------------------------------------------------
|
|
||||||
|
|
||||||
const Catering = ({ onError }: { onError: (message: string) => void }) => {
|
|
||||||
const [tables, setTables] = useState<CateringTable[] | null>(null);
|
|
||||||
const [pageNotes, setPageNotes] = useState<string[]>([]);
|
|
||||||
const [storedNotes, setStoredNotes] = useState<string[]>([]);
|
|
||||||
const [fresh, setFresh] = useState('');
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const menu = await adminCatering();
|
|
||||||
setTables(menu.packages);
|
|
||||||
setPageNotes(menu.notes);
|
|
||||||
setStoredNotes(menu.notes);
|
|
||||||
} catch (e) {
|
|
||||||
onError(e instanceof Error ? e.message : 'Could not load the catering tables.');
|
|
||||||
setTables([]);
|
|
||||||
}
|
|
||||||
}, [onError]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
/** Moving a table applies on screen first; this page shouldn't freeze between clicks. */
|
|
||||||
const settle = async (optimistic: CateringTable[], work: () => Promise<unknown>) => {
|
|
||||||
const before = tables ?? [];
|
|
||||||
setTables(optimistic);
|
|
||||||
try {
|
|
||||||
await work();
|
|
||||||
} catch (e) {
|
|
||||||
setTables(before);
|
|
||||||
onError(e instanceof Error ? e.message : 'That did not save.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const guard = async (work: () => Promise<unknown>) => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
await work();
|
|
||||||
} catch (e) {
|
|
||||||
onError(e instanceof Error ? e.message : 'That did not save.');
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const notesDirty = JSON.stringify(pageNotes) !== JSON.stringify(storedNotes);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h2 className="font-adbhashitha text-xl text-bakery-800">Goodie boxes & catering</h2>
|
|
||||||
<p className="mt-1 text-sm text-bakery-600">
|
|
||||||
The price tables, in the order they appear on the page. Each one saves on its own.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{tables === null ? (
|
|
||||||
<p className="mt-3 text-bakery-600">Loading…</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ul className="mt-3 space-y-4">
|
|
||||||
{tables.map((table, i) => (
|
|
||||||
<TableCard
|
|
||||||
key={table.id}
|
|
||||||
table={table}
|
|
||||||
first={i === 0}
|
|
||||||
last={i === tables.length - 1}
|
|
||||||
onError={onError}
|
|
||||||
onSaved={(saved) => setTables(tables.map((t) => (t.id === saved.id ? saved : t)))}
|
|
||||||
onMove={(delta) => {
|
|
||||||
const moved = shift(tables, i, delta);
|
|
||||||
void settle(moved, () => reorderCateringTables(moved.map((t) => t.id)));
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
if (!confirm(`Delete the ${table.name} table and everything in it?`)) return;
|
|
||||||
void settle(
|
|
||||||
tables.filter((t) => t.id !== table.id),
|
|
||||||
() => deleteCateringTable(table.id),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="mt-4 flex gap-2">
|
|
||||||
<input
|
|
||||||
className={field}
|
|
||||||
value={fresh}
|
|
||||||
onChange={(e) => setFresh(e.target.value)}
|
|
||||||
placeholder="New table, e.g. Graduation parties"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={primary}
|
|
||||||
disabled={busy || !fresh.trim()}
|
|
||||||
onClick={() =>
|
|
||||||
void guard(async () => {
|
|
||||||
const added = await addCateringTable(fresh.trim());
|
|
||||||
setTables([...(tables ?? []), added]);
|
|
||||||
setFresh('');
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon d={PLUS} />
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
|
|
||||||
<h3 className="font-adbhashitha text-lg text-bakery-800">Under the whole page</h3>
|
|
||||||
<div className="mt-2">
|
|
||||||
<Notes
|
|
||||||
notes={pageNotes}
|
|
||||||
hint="Terms that apply whichever table someone is reading."
|
|
||||||
disabled={busy}
|
|
||||||
onChange={setPageNotes}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={primary}
|
|
||||||
disabled={busy || !notesDirty}
|
|
||||||
onClick={() =>
|
|
||||||
void guard(async () => {
|
|
||||||
const saved = await saveCateringNotes(pageNotes);
|
|
||||||
setPageNotes(saved);
|
|
||||||
setStoredNotes(saved);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon d={CHECK} />
|
|
||||||
Save these notes
|
|
||||||
</button>
|
|
||||||
{notesDirty && (
|
|
||||||
<button type="button" className={secondary} onClick={() => setPageNotes(storedNotes)}>
|
|
||||||
<Icon d={X} />
|
|
||||||
Undo
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Catering;
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* The small shared pieces of the admin screens: one icon set, one set of button and field looks.
|
|
||||||
*
|
|
||||||
* Extracted from the catalogue editor when the catering tables arrived, so the two screens can't
|
|
||||||
* drift into looking like two different products.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const Icon = ({ d, className = '' }: { d: string; className?: string }) => (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className={`w-4 h-4 ${className}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path d={d} />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ARROW_UP = 'M12 19V5M5 12l7-7 7 7';
|
|
||||||
export const ARROW_DOWN = 'M12 5v14M19 12l-7 7-7-7';
|
|
||||||
export const ARROW_LEFT = 'M19 12H5M12 19l-7-7 7-7';
|
|
||||||
export const ARROW_RIGHT = 'M5 12h14M12 5l7 7-7 7';
|
|
||||||
export const TRASH = 'M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6';
|
|
||||||
export const PLUS = 'M12 5v14M5 12h14';
|
|
||||||
export const CHECK = 'M20 6L9 17l-5-5';
|
|
||||||
export const X = 'M18 6L6 18M6 6l12 12';
|
|
||||||
|
|
||||||
const button =
|
|
||||||
'inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium ' +
|
|
||||||
'transition-colors disabled:opacity-40 disabled:cursor-not-allowed';
|
|
||||||
export const primary = `${button} bg-bakery-600 text-white hover:bg-bakery-700`;
|
|
||||||
export const secondary = `${button} border border-bakery-300 text-bakery-800 hover:bg-bakery-100`;
|
|
||||||
export const danger = `${button} text-red-700 hover:bg-red-50`;
|
|
||||||
export const iconButton =
|
|
||||||
'inline-flex items-center justify-center w-7 h-7 rounded-md border border-bakery-300 ' +
|
|
||||||
'text-bakery-700 hover:bg-bakery-100 transition-colors disabled:opacity-30 disabled:cursor-not-allowed';
|
|
||||||
export const field =
|
|
||||||
'w-full rounded-md border border-bakery-300 bg-white px-3 py-2 text-sm ' +
|
|
||||||
'focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500';
|
|
||||||
|
|
||||||
/** Moves one entry of a list by `delta`, or returns the list untouched if that would fall off an end. */
|
|
||||||
export function shift<T>(items: T[], index: number, delta: number): T[] {
|
|
||||||
const target = index + delta;
|
|
||||||
if (target < 0 || target >= items.length) return items;
|
|
||||||
const next = [...items];
|
|
||||||
[next[index], next[target]] = [next[target], next[index]];
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/*
|
|
||||||
* The admin screen's stylesheet.
|
|
||||||
*
|
|
||||||
* The brand itself lives in ./tokens.css, shared with the server-rendered
|
|
||||||
* site's stylesheet — one palette, one set of fonts, and no drift between the shop front and the
|
|
||||||
* screen that edits it. Everything else the admin needs comes from utility classes in the React
|
|
||||||
* source, which Tailwind finds by scanning it.
|
|
||||||
*
|
|
||||||
* The fonts are served by Spring from /fonts rather than bundled here, so both stylesheets can name
|
|
||||||
* the same URL.
|
|
||||||
*/
|
|
||||||
@import "tailwindcss";
|
|
||||||
@import "./tokens.css";
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
/**
|
|
||||||
* The catalogue, its ordering, its category filter and its image URLs are all decided by the
|
|
||||||
* backend — this file just fetches them. Same origin, so no base URL and no CORS.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface Product {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
category: string;
|
|
||||||
/** Absolute, ready to put in a src. Built server-side from the bucket config. */
|
|
||||||
images: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the admin screens get back: the public shape plus where it sits in the order. */
|
|
||||||
export interface AdminProduct extends Product {
|
|
||||||
position: number;
|
|
||||||
/** The same photos as `images`, in the same order — these are what arrangePhotos names them by. */
|
|
||||||
keys: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminCategory {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
position: number;
|
|
||||||
/** How many products are filed under it — deleting one that's in use is refused. */
|
|
||||||
used: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function get<T>(path: string): Promise<T> {
|
|
||||||
const res = await fetch(path, { headers: { Accept: 'application/json' } });
|
|
||||||
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
|
|
||||||
return res.json() as Promise<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchProducts = (category?: string) =>
|
|
||||||
get<Product[]>(category && category !== 'All' ? `/api/products?category=${encodeURIComponent(category)}` : '/api/products');
|
|
||||||
|
|
||||||
export const fetchCategories = () => get<string[]>('/api/categories');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spring hands the SPA a CSRF token in a cookie and wants it echoed on anything that writes. Read
|
|
||||||
* per request rather than cached: it rotates on sign-in, and a stale token fails exactly like a
|
|
||||||
* missing one. Returns nothing when security is off, which is why the contact form still posts
|
|
||||||
* happily on a deployment with no identity provider.
|
|
||||||
*/
|
|
||||||
export function csrfHeader(): Record<string, string> {
|
|
||||||
const token = document.cookie
|
|
||||||
.split('; ')
|
|
||||||
.find((c) => c.startsWith('XSRF-TOKEN='))
|
|
||||||
?.slice('XSRF-TOKEN='.length);
|
|
||||||
return token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every admin write funnels through here so one place understands the server's failure shapes: a
|
|
||||||
* 401/403 means the session lapsed (the OIDC chain answers /api with a status rather than bouncing
|
|
||||||
* you to a login page), and anything else carries a ProblemDetail whose `detail` is the sentence
|
|
||||||
* the server wants the editor to read.
|
|
||||||
*/
|
|
||||||
async function send<T>(path: string, method: string, body?: unknown, form?: FormData): Promise<T> {
|
|
||||||
const res = await fetch(path, {
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Accept: 'application/json',
|
|
||||||
...(form ? {} : { 'Content-Type': 'application/json' }),
|
|
||||||
...csrfHeader(),
|
|
||||||
},
|
|
||||||
body: form ?? (body === undefined ? undefined : JSON.stringify(body)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
|
||||||
throw new Error('Your sign-in has expired — refresh the page to sign in again.');
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
|
||||||
const problem = await res.json().catch(() => null);
|
|
||||||
throw new Error(problem?.detail || problem?.error || 'That did not save. Please try again.');
|
|
||||||
}
|
|
||||||
return (res.status === 204 ? undefined : await res.json()) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- products ---------------------------------------------------------------
|
|
||||||
|
|
||||||
export const adminProducts = () => get<AdminProduct[]>('/api/admin/products');
|
|
||||||
|
|
||||||
export function createProduct(name: string, category: string, photos: File[]) {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('name', name);
|
|
||||||
form.append('category', category);
|
|
||||||
photos.forEach((p) => form.append('photos', p));
|
|
||||||
return send<AdminProduct>('/api/admin/products', 'POST', undefined, form);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const describeProduct = (id: number, name: string, category: string) =>
|
|
||||||
send<AdminProduct>(`/api/admin/products/${id}`, 'PUT', { name, category });
|
|
||||||
|
|
||||||
export const deleteProduct = (id: number) =>
|
|
||||||
send<{ ok: boolean }>(`/api/admin/products/${id}`, 'DELETE');
|
|
||||||
|
|
||||||
export function addPhotos(id: number, photos: File[]) {
|
|
||||||
const form = new FormData();
|
|
||||||
photos.forEach((p) => form.append('photos', p));
|
|
||||||
return send<AdminProduct>(`/api/admin/products/${id}/photos`, 'POST', undefined, form);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The full arrangement the editor is looking at — removing a photo is just an omission. */
|
|
||||||
export const arrangePhotos = (id: number, keys: string[]) =>
|
|
||||||
send<AdminProduct>(`/api/admin/products/${id}/photos`, 'PUT', keys);
|
|
||||||
|
|
||||||
export const reorderProducts = (ids: number[]) =>
|
|
||||||
send<AdminProduct[]>('/api/admin/products/order', 'PUT', { ids });
|
|
||||||
|
|
||||||
// --- categories -------------------------------------------------------------
|
|
||||||
|
|
||||||
export const adminCategories = () => get<AdminCategory[]>('/api/admin/categories');
|
|
||||||
|
|
||||||
export const createCategory = (name: string) =>
|
|
||||||
send<AdminCategory>('/api/admin/categories', 'POST', { name });
|
|
||||||
|
|
||||||
export const renameCategory = (id: number, name: string) =>
|
|
||||||
send<AdminCategory>(`/api/admin/categories/${id}`, 'PUT', { name });
|
|
||||||
|
|
||||||
export const reorderCategories = (ids: number[]) =>
|
|
||||||
send<AdminCategory[]>('/api/admin/categories/order', 'PUT', { ids });
|
|
||||||
|
|
||||||
export const deleteCategory = (id: number) =>
|
|
||||||
send<{ ok: boolean }>(`/api/admin/categories/${id}`, 'DELETE');
|
|
||||||
|
|
||||||
// --- goodie boxes & catering ------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A column of a catering table. `price` is already written the way it should be read ("$24") — the
|
|
||||||
* server owns money, both what a typed price means and how it prints — and is null for a column that
|
|
||||||
* doesn't state one. `id` is null only for a column the editor has just added and not yet saved.
|
|
||||||
*/
|
|
||||||
export interface CateringTier {
|
|
||||||
id: number | null;
|
|
||||||
label: string;
|
|
||||||
price: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A line of a catering table, with one entry per column, in column order — blanks included. */
|
|
||||||
export interface CateringRow {
|
|
||||||
id: number | null;
|
|
||||||
label: string;
|
|
||||||
values: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One table: "Office", "Parties", "Weddings". */
|
|
||||||
export interface CateringTable {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
blurb: string | null;
|
|
||||||
tiers: CateringTier[];
|
|
||||||
rows: CateringRow[];
|
|
||||||
/** The rules under this table: minimums, what can't be mixed. */
|
|
||||||
notes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CateringMenu {
|
|
||||||
packages: CateringTable[];
|
|
||||||
/** Terms that apply to the page rather than to any one table. */
|
|
||||||
notes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A table as the editor left it, sent whole. It has to be whole: a column and the values beneath it
|
|
||||||
* only mean anything together, so moving or removing one has to carry its entries with it. The
|
|
||||||
* server rejects any table whose lines and columns disagree.
|
|
||||||
*/
|
|
||||||
export interface CateringTableEdit {
|
|
||||||
name: string;
|
|
||||||
blurb: string | null;
|
|
||||||
tiers: { id: number | null; label: string; price: string }[];
|
|
||||||
rows: { id: number | null; label: string; values: string[] }[];
|
|
||||||
notes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What a customer sees: finished tables only. */
|
|
||||||
export const fetchCatering = () => get<CateringMenu>('/api/catering');
|
|
||||||
|
|
||||||
/** What the editor sees: the same tables, including any they haven't finished filling in. */
|
|
||||||
export const adminCatering = () => get<CateringMenu>('/api/admin/catering');
|
|
||||||
|
|
||||||
export const addCateringTable = (name: string) =>
|
|
||||||
send<CateringTable>('/api/admin/catering/packages', 'POST', { name });
|
|
||||||
|
|
||||||
export const saveCateringTable = (id: number, table: CateringTableEdit) =>
|
|
||||||
send<CateringTable>(`/api/admin/catering/packages/${id}`, 'PUT', table);
|
|
||||||
|
|
||||||
export const deleteCateringTable = (id: number) =>
|
|
||||||
send<{ ok: boolean }>(`/api/admin/catering/packages/${id}`, 'DELETE');
|
|
||||||
|
|
||||||
export const reorderCateringTables = (ids: number[]) =>
|
|
||||||
send<CateringTable[]>('/api/admin/catering/packages/order', 'PUT', { ids });
|
|
||||||
|
|
||||||
/** The page's own footnotes: the full list, so removing one is an omission. */
|
|
||||||
export const saveCateringNotes = (notes: string[]) =>
|
|
||||||
send<string[]>('/api/admin/catering/notes', 'PUT', notes);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { StrictMode } from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import App from './App';
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
// No BrowserRouter: this shell is served at /admin and nowhere else, so there is nothing to route.
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,690 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"types": ["vite/client", "node"],
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"paths": { "@/*": ["./src/*"] }
|
|
||||||
},
|
|
||||||
"include": ["src", "vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { defineConfig } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the admin screen, and only that. The public site is server-rendered by Spring and never comes
|
|
||||||
* through here — its stylesheet is compiled from the Thymeleaf templates by the Tailwind CLI
|
|
||||||
* (`npm run build:css`).
|
|
||||||
*
|
|
||||||
* vite-plugin-svgr went with the public pages: the logo marks were inlined through it so they could
|
|
||||||
* take their colour from the surrounding text, and the server-rendered header does that with a CSS
|
|
||||||
* mask instead.
|
|
||||||
*/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react(), tailwindcss()],
|
|
||||||
resolve: {
|
|
||||||
alias: { '@': path.resolve(__dirname, './src') },
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
port: 2024,
|
|
||||||
// Backend on :8080 during development; the built admin is served by Spring, same origin.
|
|
||||||
proxy: {
|
|
||||||
'/api': 'http://localhost:8080',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
build: {
|
|
||||||
outDir: 'dist',
|
|
||||||
chunkSizeWarningLimit: 600,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -124,20 +124,26 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
</plugin>
|
</plugin>
|
||||||
<!-- Node install + npm install + `npm run build` (the admin screen) are inherited from
|
<!--
|
||||||
platform-parent; the extra execution below compiles the server-rendered site's
|
There is no JavaScript application here any more, so node is present for one reason:
|
||||||
stylesheet. -->
|
Tailwind needs a compiler, and the alternative to it is a hand-written stylesheet.
|
||||||
|
`styles/` is the whole asset pipeline (see styles/site.css).
|
||||||
|
-->
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>com.github.eirslett</groupId>
|
<groupId>com.github.eirslett</groupId>
|
||||||
<artifactId>frontend-maven-plugin</artifactId>
|
<artifactId>frontend-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<!-- Overrides platform-parent's `frontend`, which was where the SPA lived. -->
|
||||||
|
<workingDirectory>styles</workingDirectory>
|
||||||
|
</configuration>
|
||||||
<executions>
|
<executions>
|
||||||
<!--
|
<!--
|
||||||
Tailwind, run as a CLI over the Thymeleaf templates, straight into the build output.
|
Tailwind, over every template, straight into the build output.
|
||||||
|
|
||||||
Bound to process-classes rather than prepare-package (where the admin's Vite build
|
process-classes, not prepare-package: `mvn spring-boot:run` stops at
|
||||||
sits) because `mvn spring-boot:run` stops at process-classes: bind it any later and
|
process-classes, and bound any later a local run would serve an unstyled site. The
|
||||||
a local run serves an unstyled site. It writes to target/classes/static/css, so the
|
output goes to target/classes/static/css so the generated file can never be
|
||||||
generated file is never mistaken for a source file.
|
mistaken for a source file.
|
||||||
-->
|
-->
|
||||||
<execution>
|
<execution>
|
||||||
<id>npm-build-css</id>
|
<id>npm-build-css</id>
|
||||||
@@ -145,11 +151,26 @@
|
|||||||
<goals><goal>npm</goal></goals>
|
<goals><goal>npm</goal></goals>
|
||||||
<configuration><arguments>run build:css</arguments></configuration>
|
<configuration><arguments>run build:css</arguments></configuration>
|
||||||
</execution>
|
</execution>
|
||||||
|
<!-- Inherited from platform-parent to build an SPA at prepare-package. There isn't
|
||||||
|
one; the stylesheet is already built above. -->
|
||||||
|
<execution>
|
||||||
|
<id>npm-build</id>
|
||||||
|
<phase>none</phase>
|
||||||
|
</execution>
|
||||||
</executions>
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-resources-plugin</artifactId>
|
<artifactId>maven-resources-plugin</artifactId>
|
||||||
|
<executions>
|
||||||
|
<!-- Same: this copied frontend/dist into the jar's static/. No dist, nothing to
|
||||||
|
copy — the pages are templates and the stylesheet is written straight to
|
||||||
|
target/classes/static/css. -->
|
||||||
|
<execution>
|
||||||
|
<id>copy-frontend</id>
|
||||||
|
<phase>none</phase>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
package com.itsthevine.web;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
|
|
||||||
import com.itsthevine.web.domain.Category;
|
|
||||||
import com.itsthevine.web.domain.CategoryRepository;
|
|
||||||
import com.itsthevine.web.domain.Product;
|
|
||||||
import com.itsthevine.web.domain.ProductRepository;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The filter buttons, editable. Gated on OIDC for the same reason as the product admin: with no
|
|
||||||
* identity provider configured these endpoints shouldn't exist at all.
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/admin/categories")
|
|
||||||
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
|
||||||
public class AdminCategoryController {
|
|
||||||
|
|
||||||
private final CategoryRepository categories;
|
|
||||||
private final ProductRepository products;
|
|
||||||
|
|
||||||
public AdminCategoryController(CategoryRepository categories, ProductRepository products) {
|
|
||||||
this.categories = categories;
|
|
||||||
this.products = products;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** {@code used} tells the editor whether deleting it would strand anything. */
|
|
||||||
public record AdminView(Long id, String name, int position, long used) {}
|
|
||||||
|
|
||||||
public record Name(String name) {}
|
|
||||||
|
|
||||||
public record Order(List<Long> ids) {}
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<AdminView> list() {
|
|
||||||
List<Product> all = products.findAllByOrderByPositionAsc();
|
|
||||||
return categories.findAllByOrderByPositionAsc().stream()
|
|
||||||
.map(c -> new AdminView(c.getId(), c.getName(), c.getPosition(), count(all, c.getName())))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
@Transactional
|
|
||||||
public AdminView create(@RequestBody Name body) {
|
|
||||||
String name = required(body.name());
|
|
||||||
categories.findByNameIgnoreCase(name).ifPresent(existing -> {
|
|
||||||
throw new IllegalStateException("There's already a " + existing.getName() + " category.");
|
|
||||||
});
|
|
||||||
int last = categories.findAllByOrderByPositionAsc().stream()
|
|
||||||
.mapToInt(Category::getPosition).max().orElse(0);
|
|
||||||
Category saved = categories.save(new Category(name, last + 1));
|
|
||||||
return new AdminView(saved.getId(), saved.getName(), saved.getPosition(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renaming carries the products with it. They store the category by name, so without this the
|
|
||||||
* rename would orphan everything filed under the old one — it would drop off the filter and
|
|
||||||
* reappear at the end as an unlisted category.
|
|
||||||
*/
|
|
||||||
@PutMapping("/{id}")
|
|
||||||
@Transactional
|
|
||||||
public AdminView rename(@PathVariable Long id, @RequestBody Name body) {
|
|
||||||
Category category = find(id);
|
|
||||||
String name = required(body.name());
|
|
||||||
categories.findByNameIgnoreCase(name)
|
|
||||||
.filter(other -> !other.getId().equals(id))
|
|
||||||
.ifPresent(other -> {
|
|
||||||
throw new IllegalStateException("There's already a " + other.getName() + " category.");
|
|
||||||
});
|
|
||||||
|
|
||||||
String previous = category.getName();
|
|
||||||
category.rename(name);
|
|
||||||
categories.save(category);
|
|
||||||
|
|
||||||
List<Product> filed = products.findAllByCategoryOrderByPositionAsc(previous);
|
|
||||||
filed.forEach(p -> p.describe(p.getName(), name));
|
|
||||||
products.saveAll(filed);
|
|
||||||
|
|
||||||
return new AdminView(category.getId(), category.getName(), category.getPosition(), filed.size());
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/order")
|
|
||||||
@Transactional
|
|
||||||
public List<AdminView> reorder(@RequestBody Order order) {
|
|
||||||
List<Category> all = categories.findAllByOrderByPositionAsc();
|
|
||||||
List<Category> arranged = new ArrayList<>();
|
|
||||||
for (Long id : order.ids()) {
|
|
||||||
all.stream().filter(c -> c.getId().equals(id)).findFirst().ifPresent(arranged::add);
|
|
||||||
}
|
|
||||||
all.stream().filter(c -> !arranged.contains(c)).forEach(arranged::add);
|
|
||||||
|
|
||||||
int position = 1;
|
|
||||||
for (Category category : arranged) {
|
|
||||||
category.moveTo(position++);
|
|
||||||
}
|
|
||||||
categories.saveAll(arranged);
|
|
||||||
|
|
||||||
List<Product> everything = products.findAllByOrderByPositionAsc();
|
|
||||||
return arranged.stream()
|
|
||||||
.map(c -> new AdminView(c.getId(), c.getName(), c.getPosition(), count(everything, c.getName())))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
|
||||||
@Transactional
|
|
||||||
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
|
|
||||||
Category category = find(id);
|
|
||||||
long used = count(products.findAllByOrderByPositionAsc(), category.getName());
|
|
||||||
if (used > 0) {
|
|
||||||
// Refuse rather than cascade: deleting the button shouldn't quietly decide what happens to
|
|
||||||
// the items behind it.
|
|
||||||
throw new IllegalStateException(
|
|
||||||
used + " item" + (used == 1 ? " is" : "s are") + " still filed under "
|
|
||||||
+ category.getName() + ". Move them first.");
|
|
||||||
}
|
|
||||||
categories.delete(category);
|
|
||||||
return ResponseEntity.ok(Map.of("ok", true));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Category find(Long id) {
|
|
||||||
return categories.findById(id)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("That category no longer exists."));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long count(List<Product> all, String category) {
|
|
||||||
return all.stream().filter(p -> p.getCategory().equalsIgnoreCase(category)).count();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String required(String value) {
|
|
||||||
String trimmed = value == null ? "" : value.trim();
|
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("Please give the category a name.");
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +1,327 @@
|
|||||||
package com.itsthevine.web;
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The catering tables, editable from the site — prices move, and moving them shouldn't need a deploy.
|
* The catering price tables, editable as forms.
|
||||||
*
|
*
|
||||||
* <p>Conditional on OIDC for the same reason as the product and category admins: with no identity
|
* <p>A table is edited and saved whole, which is the same rule the React screen followed and for the
|
||||||
* provider configured the platform runs its permit-all chain, so an unconditional controller here
|
* same reason: a column heading, its price and the entries beneath it only mean anything together, so
|
||||||
* would publish price writes to the open internet on any deployment that forgot to wire Authentik.
|
* they have to be added, moved and removed together. {@code CateringPackage#arrange} refuses an
|
||||||
* Gated this way, "no auth configured" means these endpoints simply don't exist.
|
* arrangement whose lines and columns disagree.
|
||||||
*
|
*
|
||||||
* <p>A table is saved whole rather than field by field. That isn't a shortcut — the columns and the
|
* <p>The interesting part is doing that without JavaScript. One form holds the whole table, and its
|
||||||
* values under them only mean anything together, so they have to arrive together (see
|
* buttons all submit it — {@code name="do"} says which one was pressed. "Add a column" therefore arrives
|
||||||
* {@code CateringPackage#arrange}).
|
* with every cell the editor has typed so far, adds the column to what arrived, and re-renders; nothing
|
||||||
|
* typed is lost, and nothing is written until Save. The alternative — a link that adds a column
|
||||||
|
* server-side — would have to either discard the unsaved edits or write a half-built table to the live
|
||||||
|
* page.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@Controller
|
||||||
@RequestMapping("/api/admin/catering")
|
@RequestMapping("/admin/catering")
|
||||||
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
||||||
public class AdminCateringController {
|
public class AdminCateringController {
|
||||||
|
|
||||||
private final CateringMenu menu;
|
private final CateringMenu catering;
|
||||||
|
|
||||||
public AdminCateringController(CateringMenu menu) {
|
public AdminCateringController(CateringMenu catering) {
|
||||||
this.menu = menu;
|
this.catering = catering;
|
||||||
}
|
}
|
||||||
|
|
||||||
public record Name(String name) {}
|
/**
|
||||||
|
* One table, as the form posts it back.
|
||||||
|
*
|
||||||
|
* <p>A form-backing object rather than a pile of {@code @RequestParam} lists, because the cells are a
|
||||||
|
* grid: Spring binds {@code lines[2].values[1]} into exactly the right place, whereas flat repeated
|
||||||
|
* parameters would rely on the browser's submission order to keep the grid square.
|
||||||
|
*/
|
||||||
|
public static class TableForm {
|
||||||
|
|
||||||
public record Order(List<Long> ids) {}
|
private String name = "";
|
||||||
|
private String blurb = "";
|
||||||
|
private List<ColumnForm> columns = new ArrayList<>();
|
||||||
|
private List<LineForm> lines = new ArrayList<>();
|
||||||
|
private List<String> notes = new ArrayList<>();
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getBlurb() { return blurb; }
|
||||||
|
public void setBlurb(String blurb) { this.blurb = blurb; }
|
||||||
|
|
||||||
|
public List<ColumnForm> getColumns() { return columns; }
|
||||||
|
public void setColumns(List<ColumnForm> columns) { this.columns = columns; }
|
||||||
|
|
||||||
|
public List<LineForm> getLines() { return lines; }
|
||||||
|
public void setLines(List<LineForm> lines) { this.lines = lines; }
|
||||||
|
|
||||||
|
public List<String> getNotes() { return notes; }
|
||||||
|
public void setNotes(List<String> notes) { this.notes = notes; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class ColumnForm {
|
||||||
|
|
||||||
|
/** Null for a column the editor has just added and not yet saved. */
|
||||||
|
private Long id;
|
||||||
|
private String label = "";
|
||||||
|
private String price = "";
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getLabel() { return label; }
|
||||||
|
public void setLabel(String label) { this.label = label; }
|
||||||
|
|
||||||
|
public String getPrice() { return price; }
|
||||||
|
public void setPrice(String price) { this.price = price; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LineForm {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String label = "";
|
||||||
|
private List<String> values = new ArrayList<>();
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getLabel() { return label; }
|
||||||
|
public void setLabel(String label) { this.label = label; }
|
||||||
|
|
||||||
|
public List<String> getValues() { return values; }
|
||||||
|
public void setValues(List<String> values) { this.values = values; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tables, listed: rename or fill one in on its own page, and set the page's own notes here. */
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public CateringMenu.MenuView all() {
|
public String tables(Model model) {
|
||||||
return menu.everything();
|
model.addAttribute("menu", catering.everything());
|
||||||
|
return "admin/catering";
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/packages")
|
/**
|
||||||
public CateringMenu.PackageView add(@RequestBody Name body) {
|
* One table's editor.
|
||||||
return menu.add(body.name());
|
*
|
||||||
|
* <p>A page per table rather than every table on one screen: a table is saved whole, so the thing
|
||||||
|
* being edited and the thing being saved should be the same thing you can see.
|
||||||
|
*/
|
||||||
|
@GetMapping("/tables/{id}")
|
||||||
|
public String edit(@PathVariable Long id, Model model) {
|
||||||
|
CateringMenu.PackageView table = catering.everything().packages().stream()
|
||||||
|
.filter(p -> p.id().equals(id))
|
||||||
|
.findFirst()
|
||||||
|
.orElse(null);
|
||||||
|
if (table == null) {
|
||||||
|
return "redirect:/admin/catering";
|
||||||
|
}
|
||||||
|
model.addAttribute("table", formOf(table));
|
||||||
|
model.addAttribute("tableId", id);
|
||||||
|
return "admin/table";
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/packages/{id}")
|
@PostMapping("/tables")
|
||||||
public CateringMenu.PackageView save(@PathVariable Long id,
|
public String add(@RequestParam String name, RedirectAttributes flash) {
|
||||||
@RequestBody CateringMenu.PackageEdit edit) {
|
try {
|
||||||
return menu.save(id, edit);
|
catering.add(name);
|
||||||
|
flash.addFlashAttribute("done", "Added the " + name.trim() + " table. Give it a column and a line.");
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
flash.addFlashAttribute("problem", e.getMessage());
|
||||||
|
}
|
||||||
|
return "redirect:/admin/catering";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mapped above {@code /packages/{id}} by Spring's literal-beats-template rule, as with products. */
|
/**
|
||||||
@PutMapping("/packages/order")
|
* Save, or restructure and come back.
|
||||||
public List<CateringMenu.PackageView> reorder(@RequestBody Order order) {
|
*
|
||||||
return menu.reorder(order.ids());
|
* @param action which button was pressed: {@code save}, {@code add-column}, {@code add-line}, or one
|
||||||
|
* of {@code remove-column}/{@code move-column}/{@code remove-line}/{@code move-line}
|
||||||
|
* with the position after a colon ({@code move-column:2:-1}). A button can only send
|
||||||
|
* its own name and value, so the value carries the argument.
|
||||||
|
*/
|
||||||
|
@PostMapping("/tables/{id}")
|
||||||
|
public String save(@PathVariable Long id,
|
||||||
|
@ModelAttribute("table") TableForm form,
|
||||||
|
@RequestParam(name = "do", defaultValue = "save") String action,
|
||||||
|
Model model,
|
||||||
|
RedirectAttributes flash) {
|
||||||
|
if (!action.equals("save")) {
|
||||||
|
restructure(form, action);
|
||||||
|
// Deliberately NOT a redirect: this is a draft, not a saved state. Re-rendering the form the
|
||||||
|
// editor is looking at keeps every cell they have typed; writing it now would put a column
|
||||||
|
// headed "" on the live page, and the table refuses that anyway.
|
||||||
|
model.addAttribute("tableId", id);
|
||||||
|
model.addAttribute("unsaved", true);
|
||||||
|
return "admin/table";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
catering.save(id, new CateringMenu.PackageEdit(
|
||||||
|
form.getName(),
|
||||||
|
form.getBlurb(),
|
||||||
|
form.getColumns().stream()
|
||||||
|
.map(c -> new CateringMenu.TierEdit(c.getId(), c.getLabel(), c.getPrice()))
|
||||||
|
.toList(),
|
||||||
|
form.getLines().stream()
|
||||||
|
.map(l -> new CateringMenu.RowEdit(l.getId(), l.getLabel(), l.getValues()))
|
||||||
|
.toList(),
|
||||||
|
form.getNotes()));
|
||||||
|
flash.addFlashAttribute("done", "Saved the " + form.getName().trim() + " table.");
|
||||||
|
return "redirect:/admin/catering";
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
// Back to the form with what they typed, and the reason. A redirect here would throw away the
|
||||||
|
// work and leave them guessing which cell the message was about.
|
||||||
|
model.addAttribute("tableId", id);
|
||||||
|
model.addAttribute("unsaved", true);
|
||||||
|
model.addAttribute("problem", e.getMessage());
|
||||||
|
return "admin/table";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/packages/{id}")
|
@PostMapping("/tables/{id}/move")
|
||||||
public ResponseEntity<Map<String, Object>> remove(@PathVariable Long id) {
|
public String move(@PathVariable Long id, @RequestParam int by) {
|
||||||
menu.remove(id);
|
List<Long> ids = new ArrayList<>(catering.everything().packages().stream()
|
||||||
return ResponseEntity.ok(Map.of("ok", true));
|
.map(CateringMenu.PackageView::id).toList());
|
||||||
|
int at = ids.indexOf(id);
|
||||||
|
int to = at + by;
|
||||||
|
if (at >= 0 && to >= 0 && to < ids.size()) {
|
||||||
|
swap(ids, at, to);
|
||||||
|
catering.reorder(ids);
|
||||||
|
}
|
||||||
|
return "redirect:/admin/catering";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The page's own footnotes: the full list the editor is looking at. */
|
@PostMapping("/tables/{id}/delete")
|
||||||
@PutMapping("/notes")
|
public String remove(@PathVariable Long id, RedirectAttributes flash) {
|
||||||
public List<String> notes(@RequestBody List<String> bodies) {
|
try {
|
||||||
return menu.replaceNotes(bodies);
|
catering.remove(id);
|
||||||
|
flash.addFlashAttribute("done", "Table deleted.");
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
flash.addFlashAttribute("problem", e.getMessage());
|
||||||
|
}
|
||||||
|
return "redirect:/admin/catering";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/notes")
|
||||||
|
public String notes(@RequestParam(name = "notes", required = false) List<String> notes,
|
||||||
|
RedirectAttributes flash) {
|
||||||
|
try {
|
||||||
|
catering.replaceNotes(notes == null ? List.of() : notes);
|
||||||
|
flash.addFlashAttribute("done", "Saved the notes for the page.");
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
flash.addFlashAttribute("problem", e.getMessage());
|
||||||
|
}
|
||||||
|
return "redirect:/admin/catering";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a structural button to the draft that arrived.
|
||||||
|
*
|
||||||
|
* <p>Adding a column adds an empty entry to every line, and removing one takes its entries with it,
|
||||||
|
* which is the invariant the aggregate insists on. Doing it here rather than in the browser is the
|
||||||
|
* whole point: there is one implementation of "a table has as many entries per line as it has
|
||||||
|
* columns", and it is in Java.
|
||||||
|
*/
|
||||||
|
private static void restructure(TableForm form, String action) {
|
||||||
|
String[] parts = action.split(":");
|
||||||
|
String what = parts[0];
|
||||||
|
int at = parts.length > 1 ? Integer.parseInt(parts[1]) : -1;
|
||||||
|
int by = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
|
||||||
|
|
||||||
|
switch (what) {
|
||||||
|
case "add-column" -> {
|
||||||
|
form.getColumns().add(new ColumnForm());
|
||||||
|
form.getLines().forEach(line -> line.getValues().add(""));
|
||||||
|
}
|
||||||
|
case "remove-column" -> {
|
||||||
|
if (inRange(at, form.getColumns().size())) {
|
||||||
|
form.getColumns().remove(at);
|
||||||
|
form.getLines().forEach(line -> {
|
||||||
|
if (inRange(at, line.getValues().size())) {
|
||||||
|
line.getValues().remove(at);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "move-column" -> {
|
||||||
|
int to = at + by;
|
||||||
|
if (inRange(at, form.getColumns().size()) && inRange(to, form.getColumns().size())) {
|
||||||
|
swap(form.getColumns(), at, to);
|
||||||
|
form.getLines().forEach(line -> swap(line.getValues(), at, to));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "add-line" -> {
|
||||||
|
LineForm line = new LineForm();
|
||||||
|
form.getColumns().forEach(column -> line.getValues().add(""));
|
||||||
|
form.getLines().add(line);
|
||||||
|
}
|
||||||
|
case "remove-line" -> {
|
||||||
|
if (inRange(at, form.getLines().size())) {
|
||||||
|
form.getLines().remove(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "move-line" -> {
|
||||||
|
int to = at + by;
|
||||||
|
if (inRange(at, form.getLines().size()) && inRange(to, form.getLines().size())) {
|
||||||
|
swap(form.getLines(), at, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "add-note" -> form.getNotes().add("");
|
||||||
|
case "remove-note" -> {
|
||||||
|
if (inRange(at, form.getNotes().size())) {
|
||||||
|
form.getNotes().remove(at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// An unknown action is a stale page or a hand-edited form: leave the draft exactly as it is
|
||||||
|
// rather than guessing at an edit nobody asked for.
|
||||||
|
default -> { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored table, as a form to edit. */
|
||||||
|
private static TableForm formOf(CateringMenu.PackageView table) {
|
||||||
|
TableForm form = new TableForm();
|
||||||
|
form.setName(table.name());
|
||||||
|
form.setBlurb(table.blurb() == null ? "" : table.blurb());
|
||||||
|
form.setColumns(table.tiers().stream().map(tier -> {
|
||||||
|
ColumnForm column = new ColumnForm();
|
||||||
|
column.setId(tier.id());
|
||||||
|
column.setLabel(tier.label());
|
||||||
|
// The price comes back written out ("$24") and goes out again as whatever is left in the box;
|
||||||
|
// Money reads either.
|
||||||
|
column.setPrice(tier.price() == null ? "" : tier.price());
|
||||||
|
return column;
|
||||||
|
}).collect(Collectors.toCollection(ArrayList::new)));
|
||||||
|
form.setLines(table.rows().stream().map(row -> {
|
||||||
|
LineForm line = new LineForm();
|
||||||
|
line.setId(row.id());
|
||||||
|
line.setLabel(row.label());
|
||||||
|
line.setValues(new ArrayList<>(row.values()));
|
||||||
|
return line;
|
||||||
|
}).collect(Collectors.toCollection(ArrayList::new)));
|
||||||
|
form.setNotes(new ArrayList<>(table.notes()));
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean inRange(int at, int size) {
|
||||||
|
return at >= 0 && at < size;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> void swap(List<T> items, int a, int b) {
|
||||||
|
T held = items.get(a);
|
||||||
|
items.set(a, items.get(b));
|
||||||
|
items.set(b, held);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The catalogue, editable by the person who bakes it — as pages and form posts.
|
||||||
|
*
|
||||||
|
* <p>Every write is POST, then a redirect back to the page it came from. That is not ceremony: it means
|
||||||
|
* the browser's back button and reload do what they look like they do, a double-tap can't repeat an
|
||||||
|
* upload, and there is no client-side state to lose. The message the editor reads afterwards travels as
|
||||||
|
* a flash attribute.
|
||||||
|
*
|
||||||
|
* <p>The whole controller is conditional on OIDC being switched on, the same as the JSON admin it
|
||||||
|
* replaced. That is deliberate belt-and-braces: the platform's permit-all filter chain is what runs when
|
||||||
|
* {@code platform.security.mode} is unset, so if these pages existed unconditionally a deployment that
|
||||||
|
* forgot to configure Authentik would be publishing catalogue writes to the open internet. Gated this
|
||||||
|
* way, "no auth configured" means "no admin" — the paths 404 like any other unknown URL.
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/admin")
|
||||||
|
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
||||||
|
public class AdminController {
|
||||||
|
|
||||||
|
private final Catalogue catalogue;
|
||||||
|
|
||||||
|
public AdminController(Catalogue catalogue) {
|
||||||
|
this.catalogue = catalogue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public String catalogue(Model model) {
|
||||||
|
model.addAttribute("items", catalogue.items());
|
||||||
|
model.addAttribute("filters", catalogue.filters());
|
||||||
|
return "admin/catalogue";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- items ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@PostMapping("/items")
|
||||||
|
public String add(@RequestParam String name,
|
||||||
|
@RequestParam String category,
|
||||||
|
@RequestParam(name = "photos", required = false) List<MultipartFile> photos,
|
||||||
|
RedirectAttributes flash) {
|
||||||
|
return run(flash, () -> {
|
||||||
|
catalogue.addItem(name, category, photos);
|
||||||
|
flash.addFlashAttribute("done", "Added " + name.trim() + " to the top of the page.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/items/{id}")
|
||||||
|
public String describe(@PathVariable Long id,
|
||||||
|
@RequestParam String name,
|
||||||
|
@RequestParam String category,
|
||||||
|
RedirectAttributes flash) {
|
||||||
|
return run(flash, () -> {
|
||||||
|
catalogue.describeItem(id, name, category);
|
||||||
|
flash.addFlashAttribute("done", "Saved " + name.trim() + ".");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/items/{id}/photos")
|
||||||
|
public String addPhotos(@PathVariable Long id,
|
||||||
|
@RequestParam(name = "photos", required = false) List<MultipartFile> photos,
|
||||||
|
RedirectAttributes flash) {
|
||||||
|
return run(flash, () -> {
|
||||||
|
catalogue.addPhotos(id, photos);
|
||||||
|
flash.addFlashAttribute("done", "Photos added.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param move {@code -1} or {@code 1} to shuffle the photo along, {@code 0} to remove it. One
|
||||||
|
* endpoint for the three buttons under a photo, because they are the same edit — which
|
||||||
|
* photos, in which order — and the server is what decides the resulting list.
|
||||||
|
*/
|
||||||
|
@PostMapping("/items/{id}/photos/arrange")
|
||||||
|
public String arrangePhoto(@PathVariable Long id,
|
||||||
|
@RequestParam String key,
|
||||||
|
@RequestParam int move,
|
||||||
|
RedirectAttributes flash) {
|
||||||
|
return run(flash, () -> {
|
||||||
|
if (move == 0) {
|
||||||
|
catalogue.removePhoto(id, key);
|
||||||
|
} else {
|
||||||
|
catalogue.movePhoto(id, key, move);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/items/{id}/move")
|
||||||
|
public String move(@PathVariable Long id, @RequestParam int by, RedirectAttributes flash) {
|
||||||
|
return run(flash, () -> catalogue.moveItem(id, by));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/items/{id}/delete")
|
||||||
|
public String remove(@PathVariable Long id, RedirectAttributes flash) {
|
||||||
|
return run(flash, () -> {
|
||||||
|
catalogue.removeItem(id);
|
||||||
|
flash.addFlashAttribute("done", "Removed from the products page.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- filters -------------------------------------------------------------
|
||||||
|
|
||||||
|
@PostMapping("/categories")
|
||||||
|
public String addFilter(@RequestParam String name, RedirectAttributes flash) {
|
||||||
|
return run(flash, () -> {
|
||||||
|
catalogue.addFilter(name);
|
||||||
|
flash.addFlashAttribute("done", "Added the " + name.trim() + " category.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/categories/{id}")
|
||||||
|
public String renameFilter(@PathVariable Long id, @RequestParam String name, RedirectAttributes flash) {
|
||||||
|
return run(flash, () -> {
|
||||||
|
catalogue.renameFilter(id, name);
|
||||||
|
flash.addFlashAttribute("done", "Renamed to " + name.trim() + ", and everything filed under it moved too.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/categories/{id}/move")
|
||||||
|
public String moveFilter(@PathVariable Long id, @RequestParam int by, RedirectAttributes flash) {
|
||||||
|
return run(flash, () -> catalogue.moveFilter(id, by));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/categories/{id}/delete")
|
||||||
|
public String removeFilter(@PathVariable Long id, RedirectAttributes flash) {
|
||||||
|
return run(flash, () -> {
|
||||||
|
catalogue.removeFilter(id);
|
||||||
|
flash.addFlashAttribute("done", "Category deleted.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one edit and comes back to the page.
|
||||||
|
*
|
||||||
|
* <p>The two exception types are the vocabulary the domain already speaks — {@code
|
||||||
|
* IllegalArgumentException} for "that isn't a usable value", {@code IllegalStateException} for "not
|
||||||
|
* while things are like this" — and both carry a sentence written for the editor to read. The
|
||||||
|
* platform's exception handler turns them into JSON for the API; here they belong on the page.
|
||||||
|
*/
|
||||||
|
private String run(RedirectAttributes flash, Runnable edit) {
|
||||||
|
try {
|
||||||
|
edit.run();
|
||||||
|
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||||
|
flash.addFlashAttribute("problem", e.getMessage());
|
||||||
|
}
|
||||||
|
return "redirect:/admin";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
package com.itsthevine.web;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import com.itsthevine.web.domain.Product;
|
|
||||||
import com.itsthevine.web.domain.ProductRepository;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Editing the catalogue from the site, so a new cake is a photo and a name rather than a migration.
|
|
||||||
*
|
|
||||||
* The whole controller is conditional on OIDC being switched on. That is deliberate belt-and-braces:
|
|
||||||
* the platform's permit-all filter chain is what runs when {@code platform.security.mode} is unset,
|
|
||||||
* so if these endpoints existed unconditionally a deployment that forgot to configure Authentik
|
|
||||||
* would be publishing catalogue writes to the open internet. Gated this way, "no auth configured"
|
|
||||||
* means "no admin endpoints" — they 404 like any other unknown path, which is also what the platform
|
|
||||||
* web contract expects of {@code /api/**}.
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/admin/products")
|
|
||||||
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
|
||||||
public class AdminProductController {
|
|
||||||
|
|
||||||
private final ProductRepository products;
|
|
||||||
private final ProductPhotoService photos;
|
|
||||||
private final ProductCatalog catalog;
|
|
||||||
|
|
||||||
public AdminProductController(ProductRepository products, ProductPhotoService photos, ProductCatalog catalog) {
|
|
||||||
this.products = products;
|
|
||||||
this.photos = photos;
|
|
||||||
this.catalog = catalog;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the editor sees: the catalogue in display order.
|
|
||||||
*
|
|
||||||
* {@code images} and {@code keys} are the same photos in the same order — the URLs to show and the
|
|
||||||
* identifiers to arrange by. The public view only needs the former, but an editor rearranging
|
|
||||||
* photos has to name them back to us, and the URL is a rendering of the key rather than the key
|
|
||||||
* itself.
|
|
||||||
*/
|
|
||||||
public record AdminView(Long id, String name, String category, int position,
|
|
||||||
List<String> images, List<String> keys) {}
|
|
||||||
|
|
||||||
public record Details(String name, String category) {}
|
|
||||||
|
|
||||||
public record Order(List<Long> ids) {}
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<AdminView> list() {
|
|
||||||
return products.findAllByOrderByPositionAsc().stream().map(this::toView).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* New items go to the front — the newest work is what's worth showing first, and it saves the
|
|
||||||
* editor a reorder after every upload.
|
|
||||||
*/
|
|
||||||
@PostMapping
|
|
||||||
@Transactional
|
|
||||||
public AdminView create(@RequestParam String name,
|
|
||||||
@RequestParam String category,
|
|
||||||
@RequestParam("photos") List<MultipartFile> files) {
|
|
||||||
String cleanName = required(name, "Please give it a name.");
|
|
||||||
String cleanCategory = required(category, "Please choose a category.");
|
|
||||||
if (files == null || files.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("Please add at least one photo.");
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> keys = new ArrayList<>();
|
|
||||||
for (MultipartFile file : files) {
|
|
||||||
keys.add(photos.store(bytes(file), file.getOriginalFilename(), cleanName));
|
|
||||||
}
|
|
||||||
|
|
||||||
Product saved = products.save(new Product(cleanName, cleanCategory, 0, keys));
|
|
||||||
renumberWithFirst(saved);
|
|
||||||
return toView(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
|
||||||
@Transactional
|
|
||||||
public AdminView describe(@PathVariable Long id, @RequestBody Details details) {
|
|
||||||
Product product = find(id);
|
|
||||||
product.describe(required(details.name(), "Please give it a name."),
|
|
||||||
required(details.category(), "Please choose a category."));
|
|
||||||
return toView(products.save(product));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/{id}/photos")
|
|
||||||
@Transactional
|
|
||||||
public AdminView addPhotos(@PathVariable Long id, @RequestParam("photos") List<MultipartFile> files) {
|
|
||||||
Product product = find(id);
|
|
||||||
if (files == null || files.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("Please choose a photo to add.");
|
|
||||||
}
|
|
||||||
List<String> keys = new ArrayList<>(product.getImageKeys());
|
|
||||||
for (MultipartFile file : files) {
|
|
||||||
keys.add(photos.store(bytes(file), file.getOriginalFilename(), product.getName()));
|
|
||||||
}
|
|
||||||
product.replacePhotos(keys);
|
|
||||||
return toView(products.save(product));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reordering and removal both arrive as the full list the editor arranged, so the stored order is
|
|
||||||
* whatever they last saw rather than the result of replaying moves.
|
|
||||||
*/
|
|
||||||
@PutMapping("/{id}/photos")
|
|
||||||
@Transactional
|
|
||||||
public AdminView arrangePhotos(@PathVariable Long id, @RequestBody List<String> keys) {
|
|
||||||
Product product = find(id);
|
|
||||||
List<String> existing = product.getImageKeys();
|
|
||||||
List<String> arranged = keys.stream().filter(existing::contains).distinct().toList();
|
|
||||||
if (arranged.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("An item needs at least one photo.");
|
|
||||||
}
|
|
||||||
product.replacePhotos(arranged);
|
|
||||||
return toView(products.save(product));
|
|
||||||
}
|
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
|
||||||
@Transactional
|
|
||||||
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id) {
|
|
||||||
products.delete(find(id));
|
|
||||||
return ResponseEntity.ok(Map.of("ok", true));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The ids in the order they should appear; anything omitted keeps its relative place after them. */
|
|
||||||
@PutMapping("/order")
|
|
||||||
@Transactional
|
|
||||||
public List<AdminView> reorder(@RequestBody Order order) {
|
|
||||||
List<Product> all = products.findAllByOrderByPositionAsc();
|
|
||||||
List<Product> arranged = new ArrayList<>();
|
|
||||||
for (Long id : order.ids()) {
|
|
||||||
all.stream().filter(p -> p.getId().equals(id)).findFirst().ifPresent(arranged::add);
|
|
||||||
}
|
|
||||||
all.stream().filter(p -> !arranged.contains(p)).forEach(arranged::add);
|
|
||||||
renumber(arranged);
|
|
||||||
return arranged.stream().map(this::toView).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void renumberWithFirst(Product first) {
|
|
||||||
List<Product> arranged = new ArrayList<>();
|
|
||||||
arranged.add(first);
|
|
||||||
products.findAllByOrderByPositionAsc().stream()
|
|
||||||
.filter(p -> !p.getId().equals(first.getId()))
|
|
||||||
.forEach(arranged::add);
|
|
||||||
renumber(arranged);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@code product.position} has no unique constraint, so ordering is a full renumber rather than a
|
|
||||||
* swap — forty rows, once in a while, from one editor.
|
|
||||||
*/
|
|
||||||
private void renumber(List<Product> arranged) {
|
|
||||||
int position = 1;
|
|
||||||
for (Product product : arranged) {
|
|
||||||
product.moveTo(position++);
|
|
||||||
}
|
|
||||||
products.saveAll(arranged);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Product find(Long id) {
|
|
||||||
return products.findById(id)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("That item no longer exists."));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] bytes(MultipartFile file) {
|
|
||||||
try {
|
|
||||||
return file.getBytes();
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("Could not read the uploaded photo.", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String required(String value, String message) {
|
|
||||||
String trimmed = value == null ? "" : value.trim();
|
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException(message);
|
|
||||||
}
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reuses the catalogue's URL building so admin and public pages can never disagree about a photo. */
|
|
||||||
private AdminView toView(Product product) {
|
|
||||||
ProductCatalog.ProductView view = catalog.view(product);
|
|
||||||
return new AdminView(view.id(), view.name(), view.category(), product.getPosition(),
|
|
||||||
view.images(), List.copyOf(product.getImageKeys()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import com.itsthevine.web.domain.Category;
|
||||||
|
import com.itsthevine.web.domain.CategoryRepository;
|
||||||
|
import com.itsthevine.web.domain.Product;
|
||||||
|
import com.itsthevine.web.domain.ProductRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editing the catalogue: what's on the products page, in what order, under which filter, with which
|
||||||
|
* photos.
|
||||||
|
*
|
||||||
|
* <p>This is the logic that used to sit in {@code AdminProductController} and
|
||||||
|
* {@code AdminCategoryController} when the admin was a React screen talking JSON. The screen is now
|
||||||
|
* server-rendered forms, and a form can only POST — so "move this up" arrives as an action rather than
|
||||||
|
* as the whole re-ordered list the browser had arranged. The reordering therefore happens here, which is
|
||||||
|
* where it should always have been: the browser was only ever telling us what it had already worked out.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class Catalogue {
|
||||||
|
|
||||||
|
private final ProductRepository products;
|
||||||
|
private final CategoryRepository categories;
|
||||||
|
private final ProductPhotoService photos;
|
||||||
|
private final SitePhotos urls;
|
||||||
|
|
||||||
|
public Catalogue(ProductRepository products, CategoryRepository categories,
|
||||||
|
ProductPhotoService photos, SitePhotos urls) {
|
||||||
|
this.products = products;
|
||||||
|
this.categories = categories;
|
||||||
|
this.photos = photos;
|
||||||
|
this.urls = urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A photo, as the editor needs it: the URL to look at and the key to name it by. Same pairing the
|
||||||
|
* React screen kept as two parallel arrays — a template can just walk the pairs.
|
||||||
|
*/
|
||||||
|
public record Photo(String key, String url) {}
|
||||||
|
|
||||||
|
public record Item(Long id, String name, String category, List<Photo> photos) {}
|
||||||
|
|
||||||
|
/** {@code used} tells the editor whether deleting a filter would strand anything. */
|
||||||
|
public record Filter(Long id, String name, long used) {}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Item> items() {
|
||||||
|
return products.findAllByOrderByPositionAsc().stream().map(this::toItem).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Filter> filters() {
|
||||||
|
List<Product> all = products.findAllByOrderByPositionAsc();
|
||||||
|
return categories.findAllByOrderByPositionAsc().stream()
|
||||||
|
.map(c -> new Filter(c.getId(), c.getName(), count(all, c.getName())))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- items ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New items go to the front — the newest work is what's worth showing first, and it saves the editor
|
||||||
|
* a reorder after every upload.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void addItem(String name, String category, List<MultipartFile> files) {
|
||||||
|
String cleanName = required(name, "Please give it a name.");
|
||||||
|
String cleanCategory = required(category, "Please choose a category.");
|
||||||
|
List<MultipartFile> chosen = real(files);
|
||||||
|
if (chosen.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Please add at least one photo.");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> keys = new ArrayList<>();
|
||||||
|
for (MultipartFile file : chosen) {
|
||||||
|
keys.add(photos.store(bytes(file), file.getOriginalFilename(), cleanName));
|
||||||
|
}
|
||||||
|
|
||||||
|
Product saved = products.save(new Product(cleanName, cleanCategory, 0, keys));
|
||||||
|
List<Product> arranged = new ArrayList<>();
|
||||||
|
arranged.add(saved);
|
||||||
|
products.findAllByOrderByPositionAsc().stream()
|
||||||
|
.filter(p -> !p.getId().equals(saved.getId()))
|
||||||
|
.forEach(arranged::add);
|
||||||
|
renumber(arranged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void describeItem(Long id, String name, String category) {
|
||||||
|
Product product = item(id);
|
||||||
|
product.describe(required(name, "Please give it a name."),
|
||||||
|
required(category, "Please choose a category."));
|
||||||
|
products.save(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void addPhotos(Long id, List<MultipartFile> files) {
|
||||||
|
Product product = item(id);
|
||||||
|
List<MultipartFile> chosen = real(files);
|
||||||
|
if (chosen.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Please choose a photo to add.");
|
||||||
|
}
|
||||||
|
List<String> keys = new ArrayList<>(product.getImageKeys());
|
||||||
|
for (MultipartFile file : chosen) {
|
||||||
|
keys.add(photos.store(bytes(file), file.getOriginalFilename(), product.getName()));
|
||||||
|
}
|
||||||
|
product.replacePhotos(keys);
|
||||||
|
products.save(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Order matters: the first photo is the one the products page leads with. */
|
||||||
|
@Transactional
|
||||||
|
public void movePhoto(Long id, String key, int delta) {
|
||||||
|
Product product = item(id);
|
||||||
|
List<String> keys = new ArrayList<>(product.getImageKeys());
|
||||||
|
int at = keys.indexOf(key);
|
||||||
|
if (at < 0) {
|
||||||
|
throw new IllegalArgumentException("That photo isn't on this item any more. Reload the page.");
|
||||||
|
}
|
||||||
|
int to = at + delta;
|
||||||
|
if (to < 0 || to >= keys.size()) {
|
||||||
|
// Already at an end. Nothing to do, and nothing to complain about — the button that asked
|
||||||
|
// for this is disabled in the page anyway.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
swap(keys, at, to);
|
||||||
|
product.replacePhotos(keys);
|
||||||
|
products.save(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void removePhoto(Long id, String key) {
|
||||||
|
Product product = item(id);
|
||||||
|
List<String> keys = new ArrayList<>(product.getImageKeys());
|
||||||
|
if (!keys.remove(key)) {
|
||||||
|
throw new IllegalArgumentException("That photo isn't on this item any more. Reload the page.");
|
||||||
|
}
|
||||||
|
if (keys.isEmpty()) {
|
||||||
|
// The card would have nothing to show. Deleting the item is a different, deliberate act.
|
||||||
|
throw new IllegalArgumentException("An item needs at least one photo.");
|
||||||
|
}
|
||||||
|
product.replacePhotos(keys);
|
||||||
|
products.save(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void removeItem(Long id) {
|
||||||
|
products.delete(item(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void moveItem(Long id, int delta) {
|
||||||
|
List<Product> all = products.findAllByOrderByPositionAsc();
|
||||||
|
int at = at(all.stream().map(Product::getId).toList(), id, "That item no longer exists.");
|
||||||
|
int to = at + delta;
|
||||||
|
if (to < 0 || to >= all.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
swap(all, at, to);
|
||||||
|
renumber(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- filters -------------------------------------------------------------
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void addFilter(String name) {
|
||||||
|
String clean = required(name, "Please give the category a name.");
|
||||||
|
categories.findByNameIgnoreCase(clean).ifPresent(existing -> {
|
||||||
|
throw new IllegalStateException("There's already a " + existing.getName() + " category.");
|
||||||
|
});
|
||||||
|
int last = categories.findAllByOrderByPositionAsc().stream()
|
||||||
|
.mapToInt(Category::getPosition).max().orElse(0);
|
||||||
|
categories.save(new Category(clean, last + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renaming carries the products with it. They store the category by name, so without this the rename
|
||||||
|
* would orphan everything filed under the old one — it would drop off the filter and reappear at the
|
||||||
|
* end as an unlisted category.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void renameFilter(Long id, String name) {
|
||||||
|
Category category = filter(id);
|
||||||
|
String clean = required(name, "Please give the category a name.");
|
||||||
|
categories.findByNameIgnoreCase(clean)
|
||||||
|
.filter(other -> !other.getId().equals(id))
|
||||||
|
.ifPresent(other -> {
|
||||||
|
throw new IllegalStateException("There's already a " + other.getName() + " category.");
|
||||||
|
});
|
||||||
|
|
||||||
|
String previous = category.getName();
|
||||||
|
category.rename(clean);
|
||||||
|
categories.save(category);
|
||||||
|
|
||||||
|
List<Product> filed = products.findAllByCategoryOrderByPositionAsc(previous);
|
||||||
|
filed.forEach(p -> p.describe(p.getName(), clean));
|
||||||
|
products.saveAll(filed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void moveFilter(Long id, int delta) {
|
||||||
|
List<Category> all = categories.findAllByOrderByPositionAsc();
|
||||||
|
int at = at(all.stream().map(Category::getId).toList(), id, "That category no longer exists.");
|
||||||
|
int to = at + delta;
|
||||||
|
if (to < 0 || to >= all.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
swap(all, at, to);
|
||||||
|
int position = 1;
|
||||||
|
for (Category category : all) {
|
||||||
|
category.moveTo(position++);
|
||||||
|
}
|
||||||
|
categories.saveAll(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void removeFilter(Long id) {
|
||||||
|
Category category = filter(id);
|
||||||
|
long used = count(products.findAllByOrderByPositionAsc(), category.getName());
|
||||||
|
if (used > 0) {
|
||||||
|
// Refuse rather than cascade: deleting the button shouldn't quietly decide what happens to
|
||||||
|
// the items behind it.
|
||||||
|
throw new IllegalStateException(used + " item" + (used == 1 ? " is" : "s are")
|
||||||
|
+ " still filed under " + category.getName() + ". Move them first.");
|
||||||
|
}
|
||||||
|
categories.delete(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- plumbing ------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Reuses the catalogue's URL building so the admin and the shop front agree about a photo. */
|
||||||
|
private Item toItem(Product product) {
|
||||||
|
List<Photo> pictures = product.getImageKeys().stream()
|
||||||
|
.map(key -> new Photo(key, urls.of(key)))
|
||||||
|
.toList();
|
||||||
|
return new Item(product.getId(), product.getName(), product.getCategory(), pictures);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code product.position} has no unique constraint, so ordering is a full renumber rather than a
|
||||||
|
* swap — forty rows, once in a while, from one editor.
|
||||||
|
*/
|
||||||
|
private void renumber(List<Product> arranged) {
|
||||||
|
int position = 1;
|
||||||
|
for (Product product : arranged) {
|
||||||
|
product.moveTo(position++);
|
||||||
|
}
|
||||||
|
products.saveAll(arranged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Product item(Long id) {
|
||||||
|
return products.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("That item no longer exists."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Category filter(Long id) {
|
||||||
|
return categories.findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("That category no longer exists."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int at(List<Long> ids, Long id, String gone) {
|
||||||
|
int at = ids.indexOf(id);
|
||||||
|
if (at < 0) {
|
||||||
|
throw new IllegalArgumentException(gone);
|
||||||
|
}
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> void swap(List<T> items, int a, int b) {
|
||||||
|
T held = items.get(a);
|
||||||
|
items.set(a, items.get(b));
|
||||||
|
items.set(b, held);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long count(List<Product> all, String category) {
|
||||||
|
return all.stream().filter(p -> p.getCategory().equalsIgnoreCase(category)).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An empty file input still posts a part, with no filename and no bytes. */
|
||||||
|
private static List<MultipartFile> real(List<MultipartFile> files) {
|
||||||
|
return files == null ? List.of() : files.stream().filter(f -> !f.isEmpty()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] bytes(MultipartFile file) {
|
||||||
|
try {
|
||||||
|
return file.getBytes();
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Could not read the uploaded photo.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String required(String value, String message) {
|
||||||
|
String trimmed = value == null ? "" : value.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(message);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -130,18 +130,6 @@ public class SiteController {
|
|||||||
return "contact";
|
return "contact";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The admin is still a React screen, and this is the one route that serves it.
|
|
||||||
*
|
|
||||||
* <p>The platform's SPA fallback used to do this for every extension-less path, which is why it's
|
|
||||||
* switched off in application.yaml: with the site server-rendered, forwarding an unknown URL to a
|
|
||||||
* JavaScript shell would answer a typo with a blank page and a 200 instead of the site's own 404.
|
|
||||||
*/
|
|
||||||
@GetMapping("/admin")
|
|
||||||
public String admin() {
|
|
||||||
return "forward:/index.html";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void contactMeta(Model model) {
|
private void contactMeta(Model model) {
|
||||||
meta(model, "/contact", "Contact us · " + NAME,
|
meta(model, "/contact", "Contact us · " + NAME,
|
||||||
"Get in touch with The Vine Coffeehouse + Bakery, 215 E Main Street, Princeville, "
|
"Get in touch with The Vine Coffeehouse + Bakery, 215 E Main Street, Princeville, "
|
||||||
|
|||||||
@@ -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|">↑</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|">↓</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|">↑</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|">↓</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">←</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">→</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">×</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|">↑</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|">↓</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 & 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">←</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">→</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">×</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">↑</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">↓</button>
|
||||||
|
<button type="submit" name="do" th:value="|remove-line:${l.index}|" class="btn-icon"
|
||||||
|
aria-label="Remove this line">×</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">×</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>
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
package com.itsthevine.web;
|
|
||||||
|
|
||||||
import static org.hamcrest.Matchers.containsString;
|
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
|
||||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
|
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
|
||||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
import org.springframework.web.context.WebApplicationContext;
|
|
||||||
import org.testcontainers.containers.PostgreSQLContainer;
|
|
||||||
import org.testcontainers.junit.jupiter.Container;
|
|
||||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
|
||||||
import org.testcontainers.utility.DockerImageName;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The catering admin over HTTP, signed in: the paths, the JSON field names and the shape of a refusal.
|
|
||||||
*
|
|
||||||
* <p>{@code CateringMenuTest} covers what the tables mean; this covers the surface the admin screen
|
|
||||||
* actually calls. Both matter — a table can be modelled perfectly and still be unreachable because a
|
|
||||||
* URL has a typo in it, and the screen reads the sentence out of a ProblemDetail rather than showing
|
|
||||||
* a status code.
|
|
||||||
*/
|
|
||||||
@SpringBootTest(properties = {
|
|
||||||
"SECURITY_MODE=OIDC",
|
|
||||||
// Stated outright rather than via issuer-uri, which would fetch a discovery document at
|
|
||||||
// startup — that needs the network and a real identity provider. (As in AdminSecurityTest.)
|
|
||||||
"spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize",
|
|
||||||
"spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token",
|
|
||||||
"spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks",
|
|
||||||
"spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo",
|
|
||||||
"spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username",
|
|
||||||
"spring.security.oauth2.client.registration.authentik.client-id=test",
|
|
||||||
"spring.security.oauth2.client.registration.authentik.client-secret=test",
|
|
||||||
"spring.security.oauth2.client.registration.authentik.scope=openid,profile,email",
|
|
||||||
"spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code",
|
|
||||||
"spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
|
|
||||||
"[email protected]",
|
|
||||||
"[email protected]",
|
|
||||||
"platform.storage.access-key=test",
|
|
||||||
"platform.storage.secret-key=test"
|
|
||||||
})
|
|
||||||
@Testcontainers
|
|
||||||
// Rolled back per test, so each one starts from the seeded page. MockMvc runs the controller on this
|
|
||||||
// thread, which is what lets the test's transaction wrap the whole request.
|
|
||||||
@Transactional
|
|
||||||
class AdminCateringApiTest {
|
|
||||||
|
|
||||||
@Container
|
|
||||||
@ServiceConnection
|
|
||||||
static PostgreSQLContainer<?> postgres =
|
|
||||||
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
WebApplicationContext context;
|
|
||||||
|
|
||||||
MockMvc mvc;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
// .apply(springSecurity()) is not optional: webAppContextSetup alone leaves the filter chain out.
|
|
||||||
mvc = MockMvcBuilders.webAppContextSetup(context)
|
|
||||||
.apply(SecurityMockMvcConfigurers.springSecurity())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void handsTheEditorEveryTableWithItsColumnsPricedAndItsLinesFilledIn() throws Exception {
|
|
||||||
mvc.perform(get("/api/admin/catering").with(user("morissa")))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.packages.length()").value(3))
|
|
||||||
.andExpect(jsonPath("$.packages[0].name").value("Office"))
|
|
||||||
// Written out, not a number the browser would have to format.
|
|
||||||
.andExpect(jsonPath("$.packages[0].tiers[0].price").value("$24"))
|
|
||||||
.andExpect(jsonPath("$.packages[0].rows[0].label").value("Mini muffins"))
|
|
||||||
.andExpect(jsonPath("$.packages[0].rows[0].values[1]").value("18 items"))
|
|
||||||
.andExpect(jsonPath("$.notes.length()").value(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void savesAWholeTableAtOnce() throws Exception {
|
|
||||||
String office = """
|
|
||||||
{"name":"Office boxes","blurb":"For meetings.",
|
|
||||||
"tiers":[{"id":null,"label":"Dozen","price":"$18.50"}],
|
|
||||||
"rows":[{"id":null,"label":"Mini muffins","values":["12 items"]}],
|
|
||||||
"notes":["Two days' notice, please."]}
|
|
||||||
""";
|
|
||||||
|
|
||||||
mvc.perform(put("/api/admin/catering/packages/1").with(user("morissa")).with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(office))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.name").value("Office boxes"))
|
|
||||||
// The column and line were new; they come back with ids so the next save edits them
|
|
||||||
// rather than adding more.
|
|
||||||
.andExpect(jsonPath("$.tiers[0].id").isNumber())
|
|
||||||
.andExpect(jsonPath("$.tiers[0].price").value("$18.50"))
|
|
||||||
.andExpect(jsonPath("$.rows[0].id").isNumber())
|
|
||||||
.andExpect(jsonPath("$.notes[0]").value("Two days' notice, please."));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void refusesAnArrangementThatWouldMisprintThePricesAndSaysWhy() throws Exception {
|
|
||||||
// Two columns, three entries on the line: exactly the mistake that shifts a box's contents.
|
|
||||||
String crooked = """
|
|
||||||
{"name":"Parties",
|
|
||||||
"tiers":[{"id":null,"label":"Small","price":"54"},{"id":null,"label":"Large","price":"98"}],
|
|
||||||
"rows":[{"id":null,"label":"Cake","values":["6 in","8 in","10 in"]}],
|
|
||||||
"notes":[]}
|
|
||||||
""";
|
|
||||||
|
|
||||||
mvc.perform(put("/api/admin/catering/packages/2").with(user("morissa")).with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(crooked))
|
|
||||||
.andExpect(status().isBadRequest())
|
|
||||||
// `detail` is the field the SPA shows the editor.
|
|
||||||
.andExpect(jsonPath("$.detail")
|
|
||||||
.value(containsString("3 entries but the table has 2 columns")));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void reordersTheTablesFromItsOwnPathRatherThanReadingOrderAsAnId() throws Exception {
|
|
||||||
// /packages/order and /packages/{id} are both PUT; Spring's literal-beats-template rule is
|
|
||||||
// what keeps "order" from arriving as a table id.
|
|
||||||
mvc.perform(put("/api/admin/catering/packages/order").with(user("morissa")).with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON).content("{\"ids\":[3,1,2]}"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$[0].name").value("Weddings"))
|
|
||||||
.andExpect(jsonPath("$[2].name").value("Parties"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void replacesThePageNotesWithTheListItWasGiven() throws Exception {
|
|
||||||
mvc.perform(put("/api/admin/catering/notes").with(user("morissa")).with(csrf())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content("[\"Prices may change.\",\" \"]"))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.length()").value(1))
|
|
||||||
.andExpect(jsonPath("$[0]").value("Prices may change."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package com.itsthevine.web;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||||
|
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
|
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.context.WebApplicationContext;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin, signed in and driven the way a browser drives it: form posts and redirects.
|
||||||
|
*
|
||||||
|
* <p>These replace the JSON admin's tests. The screen used to be React talking to {@code
|
||||||
|
* /api/admin/**}; it is now Thymeleaf forms, so what is worth asserting is that a form arrives bound
|
||||||
|
* correctly, that an edit lands, that a refusal comes back readable rather than as a stack trace, and
|
||||||
|
* that a structural button changes the draft without writing it.
|
||||||
|
*
|
||||||
|
* <p>Runs with {@code SECURITY_MODE=OIDC}, because that is the only condition under which the admin
|
||||||
|
* exists at all.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = {
|
||||||
|
"SECURITY_MODE=OIDC",
|
||||||
|
// Endpoints stated outright rather than an issuer-uri, which would make Spring fetch the
|
||||||
|
// discovery document at startup — that needs the network and a real identity provider.
|
||||||
|
"spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize",
|
||||||
|
"spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token",
|
||||||
|
"spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks",
|
||||||
|
"spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo",
|
||||||
|
"spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.client-id=test",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.client-secret=test",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.scope=openid,profile,email",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code",
|
||||||
|
"spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
|
||||||
|
"[email protected]",
|
||||||
|
"[email protected]",
|
||||||
|
"platform.storage.access-key=test",
|
||||||
|
"platform.storage.secret-key=test"
|
||||||
|
})
|
||||||
|
@Testcontainers
|
||||||
|
// Rolled back per test, so each starts from the seeded catalogue. MockMvc runs the controller on this
|
||||||
|
// thread, which is what lets the test's transaction wrap the whole request.
|
||||||
|
@Transactional
|
||||||
|
class AdminPagesTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
@ServiceConnection
|
||||||
|
static PostgreSQLContainer<?> postgres =
|
||||||
|
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
WebApplicationContext context;
|
||||||
|
|
||||||
|
MockMvc mvc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
// .apply(springSecurity()) is not optional: webAppContextSetup alone leaves the filter chain out.
|
||||||
|
mvc = MockMvcBuilders.webAppContextSetup(context)
|
||||||
|
.apply(SecurityMockMvcConfigurers.springSecurity())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theCatalogueScreenShowsWhatIsOnThePageWithItsPhotos() throws Exception {
|
||||||
|
mvc.perform(get("/admin").with(user("morissa")))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("76th Birthday Cake")))
|
||||||
|
// The photo URL and the key beside it: the key is what the arrange buttons name it by.
|
||||||
|
.andExpect(content().string(containsString("products/76th_birthday_cake.webp")))
|
||||||
|
.andExpect(content().string(containsString("On the page (40)")))
|
||||||
|
// The filter list, with the count that decides whether Delete is offered.
|
||||||
|
.andExpect(content().string(containsString("Cookies")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void renamingAnItemLandsAndSaysSo() throws Exception {
|
||||||
|
mvc.perform(post("/admin/items/1").with(user("morissa")).with(csrf())
|
||||||
|
.param("name", "76th Birthday Cake (chocolate)")
|
||||||
|
.param("category", "Cakes"))
|
||||||
|
.andExpect(status().is3xxRedirection())
|
||||||
|
.andExpect(redirectedUrl("/admin"))
|
||||||
|
.andExpect(flash().attribute("done", containsString("Saved")));
|
||||||
|
|
||||||
|
mvc.perform(get("/admin").with(user("morissa")))
|
||||||
|
.andExpect(content().string(containsString("76th Birthday Cake (chocolate)")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aRefusalComesBackAsASentenceTheEditorCanActOn() throws Exception {
|
||||||
|
// Cookies has items filed under it, and deleting the button shouldn't decide what happens to them.
|
||||||
|
mvc.perform(post("/admin/categories/1/delete").with(user("morissa")).with(csrf()))
|
||||||
|
.andExpect(redirectedUrl("/admin"))
|
||||||
|
.andExpect(flash().attribute("problem", containsString("still filed under Cookies")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void movingAnItemUpFromTheTopIsNotAnError() throws Exception {
|
||||||
|
// The button is disabled in the page, but a stale page could still post this.
|
||||||
|
mvc.perform(post("/admin/items/1/move").with(user("morissa")).with(csrf()).param("by", "-1"))
|
||||||
|
.andExpect(redirectedUrl("/admin"))
|
||||||
|
.andExpect(flash().attributeCount(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theTableEditorRendersTheStoredTableAsAForm() throws Exception {
|
||||||
|
mvc.perform(get("/admin/catering/tables/1").with(user("morissa")))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
// Indexed names are what let Spring bind the grid back into the right cells.
|
||||||
|
.andExpect(content().string(containsString("name=\"columns[0].label\"")))
|
||||||
|
.andExpect(content().string(containsString("name=\"lines[0].values[1]\"")))
|
||||||
|
.andExpect(content().string(containsString("value=\"18 items\"")))
|
||||||
|
// The price round-trips as text: it came out "$24" and goes back the same way.
|
||||||
|
.andExpect(content().string(containsString("value=\"$24\"")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void savingTheTableWritesEveryCell() throws Exception {
|
||||||
|
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
|
||||||
|
.param("do", "save")
|
||||||
|
.param("name", "Office boxes")
|
||||||
|
.param("blurb", "For meetings.")
|
||||||
|
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$26")
|
||||||
|
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
|
||||||
|
.param("lines[0].values[0]", "14 items")
|
||||||
|
.param("notes[0]", "Two days' notice, please."))
|
||||||
|
.andExpect(redirectedUrl("/admin/catering"))
|
||||||
|
.andExpect(flash().attribute("done", containsString("Saved the Office boxes table")));
|
||||||
|
|
||||||
|
mvc.perform(get("/api/catering"))
|
||||||
|
.andExpect(content().string(containsString("Office boxes")))
|
||||||
|
.andExpect(content().string(containsString("$26")))
|
||||||
|
.andExpect(content().string(containsString("14 items")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addingAColumnKeepsWhatWasTypedAndWritesNothing() throws Exception {
|
||||||
|
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
|
||||||
|
.param("do", "add-column")
|
||||||
|
.param("name", "Office")
|
||||||
|
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$24")
|
||||||
|
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
|
||||||
|
// A cell edited but not yet saved: it has to survive the round trip.
|
||||||
|
.param("lines[0].values[0]", "13 items"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("value=\"13 items\"")))
|
||||||
|
// The new column exists in the form, and every line grew an entry to match it.
|
||||||
|
.andExpect(content().string(containsString("name=\"columns[1].label\"")))
|
||||||
|
.andExpect(content().string(containsString("name=\"lines[0].values[1]\"")))
|
||||||
|
.andExpect(content().string(containsString("Not saved yet")));
|
||||||
|
|
||||||
|
// And nothing was written: the live page still says what it said.
|
||||||
|
mvc.perform(get("/api/catering"))
|
||||||
|
.andExpect(content().string(containsString("12 items")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void removingAColumnTakesItsCellsOutOfEveryLine() throws Exception {
|
||||||
|
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
|
||||||
|
.param("do", "remove-column:0")
|
||||||
|
.param("name", "Office")
|
||||||
|
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "$24")
|
||||||
|
.param("columns[1].id", "2").param("columns[1].label", "Medium").param("columns[1].price", "$32")
|
||||||
|
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
|
||||||
|
.param("lines[0].values[0]", "12 items")
|
||||||
|
.param("lines[0].values[1]", "18 items"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
// What is left is the Medium column and, under it, Medium's entry — not Small's.
|
||||||
|
.andExpect(content().string(containsString("value=\"Medium\"")))
|
||||||
|
.andExpect(content().string(containsString("value=\"18 items\"")))
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.not(containsString("value=\"12 items\""))))
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.not(containsString("name=\"columns[1].label\""))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aPriceThatIsntAPriceComesBackWithTheWorkStillInTheForm() throws Exception {
|
||||||
|
mvc.perform(post("/admin/catering/tables/1").with(user("morissa")).with(csrf())
|
||||||
|
.param("do", "save")
|
||||||
|
.param("name", "Office")
|
||||||
|
.param("columns[0].id", "1").param("columns[0].label", "Small").param("columns[0].price", "ask us")
|
||||||
|
.param("lines[0].id", "1").param("lines[0].label", "Mini muffins")
|
||||||
|
.param("lines[0].values[0]", "12 items"))
|
||||||
|
// Not a redirect: a redirect would throw the work away and leave them guessing.
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(content().string(containsString("isn't a price")))
|
||||||
|
.andExpect(content().string(containsString("value=\"ask us\"")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void thePageNotesAreReplacedByWhatTheFormSubmits() throws Exception {
|
||||||
|
mvc.perform(post("/admin/catering/notes").with(user("morissa")).with(csrf())
|
||||||
|
.param("notes", "Prices may change.")
|
||||||
|
.param("notes", " ")
|
||||||
|
.param("notes", "Two weeks' notice for a wedding."))
|
||||||
|
.andExpect(redirectedUrl("/admin/catering"));
|
||||||
|
|
||||||
|
mvc.perform(get("/api/catering"))
|
||||||
|
.andExpect(content().string(containsString("Two weeks' notice for a wedding.")))
|
||||||
|
// The blank box was a deletion, not a note: two notes came back, not three.
|
||||||
|
.andExpect(content().string(containsString("Prices may change.")))
|
||||||
|
.andExpect(content().string(org.hamcrest.Matchers.not(containsString("\" \""))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,21 +70,24 @@ class AdminSecurityTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void everyAdminApiIsClosedToAnonymousVisitors() throws Exception {
|
void everyAdminWriteIsClosedToAnonymousVisitors() throws Exception {
|
||||||
// csrf() on the writes, so these assert AUTHORIZATION (401), not a missing token.
|
// csrf() on the writes, so these assert AUTHORIZATION, not a missing token. The admin is pages
|
||||||
mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized());
|
// and form posts now, so this is the whole surface — there is no JSON admin left to guard.
|
||||||
mvc.perform(get("/api/admin/categories")).andExpect(status().isUnauthorized());
|
mvc.perform(get("/admin")).andExpect(status().isUnauthorized());
|
||||||
mvc.perform(get("/api/admin/catering")).andExpect(status().isUnauthorized());
|
mvc.perform(get("/admin/catering")).andExpect(status().isUnauthorized());
|
||||||
mvc.perform(post("/api/admin/products").with(csrf())).andExpect(status().isUnauthorized());
|
mvc.perform(get("/admin/catering/tables/1")).andExpect(status().isUnauthorized());
|
||||||
mvc.perform(post("/api/admin/categories").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
mvc.perform(post("/admin/items").with(csrf()).param("name", "Free cake").param("category", "Cakes"))
|
||||||
.content("{\"name\":\"x\"}"))
|
.andExpect(status().isUnauthorized());
|
||||||
|
mvc.perform(post("/admin/items/1/delete").with(csrf())).andExpect(status().isUnauthorized());
|
||||||
|
mvc.perform(post("/admin/categories").with(csrf()).param("name", "x"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
// The prices are the one thing on this site a stranger would most enjoy editing.
|
// The prices are the one thing on this site a stranger would most enjoy editing.
|
||||||
mvc.perform(put("/api/admin/catering/packages/1").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
mvc.perform(post("/admin/catering/tables/1").with(csrf())
|
||||||
.content("{\"name\":\"Free\",\"tiers\":[],\"rows\":[],\"notes\":[]}"))
|
.param("do", "save").param("name", "Free")
|
||||||
|
.param("columns[0].label", "Any").param("columns[0].price", "0")
|
||||||
|
.param("lines[0].label", "Everything").param("lines[0].values[0]", "yes"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
mvc.perform(put("/api/admin/catering/notes").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
mvc.perform(post("/admin/catering/notes").with(csrf()).param("notes", "anything"))
|
||||||
.content("[\"anything\"]"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +95,12 @@ class AdminSecurityTest {
|
|||||||
void theAdminPageRedirectsABrowserToLogin() throws Exception {
|
void theAdminPageRedirectsABrowserToLogin() throws Exception {
|
||||||
// Protecting /admin server-side is what makes sign-in work: a browser opening it is bounced to
|
// Protecting /admin server-side is what makes sign-in work: a browser opening it is bounced to
|
||||||
// Authentik and comes back signed in. The redirect only fires for a request that prefers HTML —
|
// Authentik and comes back signed in. The redirect only fires for a request that prefers HTML —
|
||||||
// the platform answers */* (a fetch/XHR) with a bare 401 so the SPA can handle it — so this
|
// the platform answers */* with a bare 401, which is why the checks above see 401 and this one
|
||||||
// must send a browser's Accept header to see the 302. (Verified against a running container.)
|
// has to send a browser's Accept header to see the 302. (Verified against a running container.)
|
||||||
mvc.perform(get("/admin").header("Accept", "text/html,application/xhtml+xml"))
|
mvc.perform(get("/admin").header("Accept", "text/html,application/xhtml+xml"))
|
||||||
.andExpect(status().is3xxRedirection());
|
.andExpect(status().is3xxRedirection());
|
||||||
|
mvc.perform(get("/admin/catering").header("Accept", "text/html,application/xhtml+xml"))
|
||||||
|
.andExpect(status().is3xxRedirection());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.itsthevine.web;
|
package com.itsthevine.web;
|
||||||
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -25,10 +24,9 @@ import org.testcontainers.utility.DockerImageName;
|
|||||||
* <p>It used to extend {@code PlatformWebContract} from platform-starter-test and inherit these five
|
* <p>It used to extend {@code PlatformWebContract} from platform-starter-test and inherit these five
|
||||||
* assertions verbatim. It can't any more, and the reason is worth stating: the shared contract asserts
|
* assertions verbatim. It can't any more, and the reason is worth stating: the shared contract asserts
|
||||||
* that an unknown extension-less path forwards to {@code /index.html}, because it was written when every
|
* that an unknown extension-less path forwards to {@code /index.html}, because it was written when every
|
||||||
* app on the platform was a React SPA. This one is server-rendered now — {@code /index.html} holds
|
* app on the platform was a React SPA. There is no SPA here at all now — no shell to forward to — so
|
||||||
* nothing but the admin shell — so forwarding a mistyped URL there would answer with a blank page and a
|
* that assertion describes an app this no longer is. The contract's own test methods are package-private,
|
||||||
* 200 instead of the site's own 404. The contract's own test methods are package-private, so the
|
* so it cannot be overridden from here.
|
||||||
* assertion cannot be overridden from here.
|
|
||||||
*
|
*
|
||||||
* <p>The other four are restated below unchanged, so this app still fails the build on the regression
|
* <p>The other four are restated below unchanged, so this app still fails the build on the regression
|
||||||
* the contract exists for (an {@code /api} typo answering with a page and a 200).
|
* the contract exists for (an {@code /api} typo answering with a page and a 200).
|
||||||
@@ -92,13 +90,12 @@ class PlatformContractTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("an unknown route is a 404, and only /admin serves the SPA shell")
|
@DisplayName("routing is server-side: an unknown path is a 404, and so is /admin with no identity provider")
|
||||||
void routingIsServerSideExceptForTheAdmin() throws Exception {
|
void routingIsServerSide() throws Exception {
|
||||||
mvc.perform(get("/some/client/side/route")).andExpect(status().isNotFound());
|
mvc.perform(get("/some/client/side/route")).andExpect(status().isNotFound());
|
||||||
// Assert the forward TARGET, not the body: MockMvc records a forward rather than executing it,
|
// No SECURITY_MODE here, so AdminController does not exist — "no Authentik configured" means "no
|
||||||
// so the body is empty here by design — which also means this passes before the admin is built.
|
// admin", and it 404s like any other unknown path rather than exposing catalogue writes.
|
||||||
mvc.perform(get("/admin"))
|
// AdminSecurityTest covers the other half: with OIDC on, /admin exists and needs a login.
|
||||||
.andExpect(status().isOk())
|
mvc.perform(get("/admin")).andExpect(status().isNotFound());
|
||||||
.andExpect(forwardedUrl("/index.html"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* The admin's controls.
|
||||||
|
*
|
||||||
|
* Component classes rather than utility strings repeated through the markup, because the admin is made
|
||||||
|
* of forms: there are dozens of buttons on a page and each one is its own <form>. The public pages keep
|
||||||
|
* their utilities inline — they each look different — but "a button in the admin" should be one decision
|
||||||
|
* in one place. These are the same buttons the React screen had, from the same class strings it composed.
|
||||||
|
*
|
||||||
|
* @utility, not `.btn { @apply … }` in a components layer: Tailwind v4 will only let you @apply a class
|
||||||
|
* it knows as a utility, and only a registered utility can take a variant — which `file:btn-secondary`
|
||||||
|
* on the photo pickers needs.
|
||||||
|
*/
|
||||||
|
@utility btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium
|
||||||
|
transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn-primary {
|
||||||
|
@apply btn bg-bakery-600 text-white hover:bg-bakery-700
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn-secondary {
|
||||||
|
@apply btn border border-bakery-300 text-bakery-800 hover:bg-bakery-100
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility btn-danger {
|
||||||
|
@apply btn text-red-700 hover:bg-red-50 disabled:opacity-40 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Square, for the arrows and the crosses — a row of these is how anything gets reordered. */
|
||||||
|
@utility btn-icon {
|
||||||
|
@apply inline-flex items-center justify-center w-7 h-7 rounded-md border border-bakery-300
|
||||||
|
text-bakery-700 hover:bg-bakery-100 transition-colors
|
||||||
|
disabled:opacity-30 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility field {
|
||||||
|
@apply w-full rounded-md border border-bakery-300 bg-white px-3 py-2 text-sm
|
||||||
|
focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility card {
|
||||||
|
@apply rounded-lg border border-bakery-200 bg-white p-4 shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility card-heading {
|
||||||
|
@apply font-adbhashitha text-xl text-bakery-800;
|
||||||
|
}
|
||||||
+3
-998
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "itsthevine-styles",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tailwindcss -i site.css -o ../target/classes/static/css/site.css --minify",
|
||||||
|
"build:css": "tailwindcss -i site.css -o ../target/classes/static/css/site.css --minify",
|
||||||
|
"watch": "tailwindcss -i site.css -o ../target/classes/static/css/site.css --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/cli": "4.3.3",
|
||||||
|
"tailwindcss": "4.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* The stylesheet for the server-rendered site.
|
||||||
|
*
|
||||||
|
* Compiled by the Tailwind CLI (`npm run build:css`) straight into target/classes/static/css: it is a
|
||||||
|
* source file, not a resource, and the generated stylesheet belongs in the build output rather than in
|
||||||
|
* src/main/resources next to it.
|
||||||
|
*
|
||||||
|
* This directory is the whole asset pipeline, and Tailwind is all that is left of it: the site and the
|
||||||
|
* admin are both server-rendered HTML, so there is no bundler, no framework and one stylesheet. It has
|
||||||
|
* to live here because Tailwind resolves `@import "tailwindcss"` by walking up from the CSS file looking
|
||||||
|
* for node_modules — and node_modules is here.
|
||||||
|
*
|
||||||
|
* @source points Tailwind at every template (public pages and admin alike) and at gallery.js — the
|
||||||
|
* product-card arrows are created in script, so their classes are only written down there. A utility
|
||||||
|
* exists in the output only if Tailwind saw it in one of these files, which is why a class name must
|
||||||
|
* never be assembled from pieces at runtime.
|
||||||
|
*/
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "./tokens.css";
|
||||||
|
@import "./admin.css";
|
||||||
|
|
||||||
|
@source "../src/main/resources/templates";
|
||||||
|
@source "../src/main/resources/static/js";
|
||||||
Reference in New Issue
Block a user