Archived
Catering, and a pure Java/Spring site: Thymeleaf front to back #10
+3
-2
@@ -1,6 +1,7 @@
|
||||
target/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
# The Tailwind CLI's dependencies. The compiled stylesheet needs no rule of its own: it is written
|
||||
# straight into target/classes/static/css, which is already ignored above.
|
||||
styles/node_modules/
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
Site for The Vine, 215 E Main Street, Princeville, Illinois. Spring Boot rendering its own pages with
|
||||
Thymeleaf, on [the Bennett platform](https://git.thebennett.net/austin/platform).
|
||||
|
||||
Previously a Next.js app on Cloudflare, then a React SPA on Spring, now server-rendered. The look has
|
||||
not changed through any of it.
|
||||
Previously a Next.js app on Cloudflare, then a React SPA on Spring, now server-rendered end to end —
|
||||
there is no JavaScript framework in this repo. The look has not changed through any of it.
|
||||
|
||||
## Shape
|
||||
|
||||
@@ -12,8 +12,8 @@ not changed through any of it.
|
||||
|---|---|
|
||||
| Backend | Spring Boot 4 / Java 25, `com.itsthevine.web` |
|
||||
| Pages | Thymeleaf, `src/main/resources/templates` — **no JavaScript** except one 100-line file for the product-card arrows |
|
||||
| Styling | Tailwind v4, compiled from the templates by the Tailwind CLI into `static/css/site.css` |
|
||||
| Admin | the one React screen that is left, served at `/admin` only |
|
||||
| Styling | Tailwind v4, compiled from the templates by the Tailwind CLI into `static/css/site.css`. `styles/` is the whole asset pipeline |
|
||||
| Admin | Thymeleaf forms at `/admin`, behind Authentik |
|
||||
| Database | Postgres (`itsthevine` on the shared `app-db` cluster), Flyway |
|
||||
| Photos | public MinIO bucket `itsthevine` — **not** in the repo or the image |
|
||||
| Deploy | Gitea CI → image → Watchtower → Caddy |
|
||||
@@ -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.
|
||||
|
||||
`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
|
||||
|
||||
@@ -64,10 +65,10 @@ Everything. The pages arrive complete.
|
||||
|
||||
## /admin
|
||||
|
||||
**The last React in the repo.** The public pages are server-rendered; this screen is a Vite/React app
|
||||
because it is not content — it is an editor, and the instant-feedback editing (reorder that applies
|
||||
before the network answers, a whole price table arranged on screen and saved in one go) is the point of
|
||||
it. Everything under `frontend/` builds only this, plus the site's stylesheet.
|
||||
Forms and redirects. Every write is a POST followed by a redirect back to the page, so the 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 — a reload is always the truth. Two screens: `/admin` is the catalogue,
|
||||
`/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
|
||||
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
|
||||
webp or ship glibc natives that don't run on Alpine).
|
||||
|
||||
The catering tables are editable there too, but a table at a time rather than a field at a time. That
|
||||
isn't a different taste in interfaces: a column heading, its price and the entries beneath it only mean
|
||||
anything together, so `CateringPackage#arrange` takes the whole table and refuses one whose lines and
|
||||
columns disagree. Drop the middle column on its own and every remaining entry shifts one place left —
|
||||
the Large box then advertises the Medium box's contents at the Large price, and nothing about the page
|
||||
looks broken.
|
||||
The catering tables are edited a table at a time rather than a field at a time. That isn't a taste in
|
||||
interfaces: a column heading, its price and the entries beneath it only mean anything together, so
|
||||
`CateringPackage#arrange` takes the whole table and refuses one whose lines and columns disagree. Drop
|
||||
the middle column on its own and every remaining entry shifts one place left — the Large box then
|
||||
advertises the Medium box's contents at the Large price, and nothing about the page looks broken.
|
||||
|
||||
**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController`,
|
||||
`AdminCategoryController` and `AdminCateringController` are `@ConditionalOnProperty` on it, so a deployment that forgets to configure
|
||||
Authentik gets 404s rather than catalogue writes open to the internet. `/admin` and `/api/admin/**` are
|
||||
both authenticated paths: a browser opening the page is sent to Authentik first, while `fetch` calls get
|
||||
a bare 401 to handle.
|
||||
**How that works without JavaScript.** One form holds the whole table and every button in it submits
|
||||
that form; `name="do"` says which was pressed and its value carries the position it applies to
|
||||
(`remove-column:2`). So "add a column" 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**: 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
|
||||
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
|
||||
|
||||
```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
|
||||
|
||||
# just the stylesheet, while editing templates — watches and recompiles
|
||||
cd frontend && npm install && npx tailwindcss -i site.css -o ../target/classes/static/css/site.css --watch
|
||||
|
||||
# the admin screen, proxying /api to :8080
|
||||
cd frontend && npm run dev # http://localhost:2024/admin
|
||||
# the stylesheet, while editing templates — watches and recompiles
|
||||
cd styles && npm install && npm run watch
|
||||
```
|
||||
|
||||
`/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
|
||||
`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.
|
||||
|
||||
@@ -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>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</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
|
||||
stylesheet. -->
|
||||
<!--
|
||||
There is no JavaScript application here any more, so node is present for one reason:
|
||||
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>
|
||||
<groupId>com.github.eirslett</groupId>
|
||||
<artifactId>frontend-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- Overrides platform-parent's `frontend`, which was where the SPA lived. -->
|
||||
<workingDirectory>styles</workingDirectory>
|
||||
</configuration>
|
||||
<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
|
||||
sits) because `mvn spring-boot:run` stops at process-classes: bind it any later and
|
||||
a local run serves an unstyled site. It writes to target/classes/static/css, so the
|
||||
generated file is never mistaken for a source file.
|
||||
process-classes, not prepare-package: `mvn spring-boot:run` stops at
|
||||
process-classes, and bound any later a local run would serve an unstyled site. The
|
||||
output goes to target/classes/static/css so the generated file can never be
|
||||
mistaken for a source file.
|
||||
-->
|
||||
<execution>
|
||||
<id>npm-build-css</id>
|
||||
@@ -145,11 +151,26 @@
|
||||
<goals><goal>npm</goal></goals>
|
||||
<configuration><arguments>run build:css</arguments></configuration>
|
||||
</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>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<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>
|
||||
</plugins>
|
||||
</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;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
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.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 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
|
||||
* provider configured the platform runs its permit-all chain, so an unconditional controller here
|
||||
* would publish price writes to the open internet on any deployment that forgot to wire Authentik.
|
||||
* Gated this way, "no auth configured" means these endpoints simply don't exist.
|
||||
* <p>A table is edited and saved whole, which is the same rule the React screen followed and for the
|
||||
* same reason: a column heading, its price and the entries beneath it only mean anything together, so
|
||||
* they have to be added, moved and removed together. {@code CateringPackage#arrange} refuses an
|
||||
* 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
|
||||
* values under them only mean anything together, so they have to arrive together (see
|
||||
* {@code CateringPackage#arrange}).
|
||||
* <p>The interesting part is doing that without JavaScript. One form holds the whole table, and its
|
||||
* buttons all submit it — {@code name="do"} says which one was pressed. "Add a column" therefore arrives
|
||||
* 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
|
||||
@RequestMapping("/api/admin/catering")
|
||||
@Controller
|
||||
@RequestMapping("/admin/catering")
|
||||
@ConditionalOnProperty(prefix = "platform.security", name = "mode", havingValue = "OIDC")
|
||||
public class AdminCateringController {
|
||||
|
||||
private final CateringMenu menu;
|
||||
private final CateringMenu catering;
|
||||
|
||||
public AdminCateringController(CateringMenu menu) {
|
||||
this.menu = menu;
|
||||
public AdminCateringController(CateringMenu catering) {
|
||||
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
|
||||
public CateringMenu.MenuView all() {
|
||||
return menu.everything();
|
||||
public String tables(Model model) {
|
||||
model.addAttribute("menu", catering.everything());
|
||||
return "admin/catering";
|
||||
}
|
||||
|
||||
@PostMapping("/packages")
|
||||
public CateringMenu.PackageView add(@RequestBody Name body) {
|
||||
return menu.add(body.name());
|
||||
/**
|
||||
* One table's editor.
|
||||
*
|
||||
* <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}")
|
||||
public CateringMenu.PackageView save(@PathVariable Long id,
|
||||
@RequestBody CateringMenu.PackageEdit edit) {
|
||||
return menu.save(id, edit);
|
||||
@PostMapping("/tables")
|
||||
public String add(@RequestParam String name, RedirectAttributes flash) {
|
||||
try {
|
||||
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")
|
||||
public List<CateringMenu.PackageView> reorder(@RequestBody Order order) {
|
||||
return menu.reorder(order.ids());
|
||||
/**
|
||||
* Save, or restructure and come back.
|
||||
*
|
||||
* @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}")
|
||||
public ResponseEntity<Map<String, Object>> remove(@PathVariable Long id) {
|
||||
menu.remove(id);
|
||||
return ResponseEntity.ok(Map.of("ok", true));
|
||||
@PostMapping("/tables/{id}/move")
|
||||
public String move(@PathVariable Long id, @RequestParam int by) {
|
||||
List<Long> ids = new ArrayList<>(catering.everything().packages().stream()
|
||||
.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. */
|
||||
@PutMapping("/notes")
|
||||
public List<String> notes(@RequestBody List<String> bodies) {
|
||||
return menu.replaceNotes(bodies);
|
||||
@PostMapping("/tables/{id}/delete")
|
||||
public String remove(@PathVariable Long id, RedirectAttributes flash) {
|
||||
try {
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
meta(model, "/contact", "Contact us · " + NAME,
|
||||
"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
|
||||
void everyAdminApiIsClosedToAnonymousVisitors() throws Exception {
|
||||
// csrf() on the writes, so these assert AUTHORIZATION (401), not a missing token.
|
||||
mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(get("/api/admin/categories")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(get("/api/admin/catering")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(post("/api/admin/products").with(csrf())).andExpect(status().isUnauthorized());
|
||||
mvc.perform(post("/api/admin/categories").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"x\"}"))
|
||||
void everyAdminWriteIsClosedToAnonymousVisitors() throws Exception {
|
||||
// csrf() on the writes, so these assert AUTHORIZATION, not a missing token. The admin is pages
|
||||
// and form posts now, so this is the whole surface — there is no JSON admin left to guard.
|
||||
mvc.perform(get("/admin")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(get("/admin/catering")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(get("/admin/catering/tables/1")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(post("/admin/items").with(csrf()).param("name", "Free cake").param("category", "Cakes"))
|
||||
.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());
|
||||
// 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)
|
||||
.content("{\"name\":\"Free\",\"tiers\":[],\"rows\":[],\"notes\":[]}"))
|
||||
mvc.perform(post("/admin/catering/tables/1").with(csrf())
|
||||
.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());
|
||||
mvc.perform(put("/api/admin/catering/notes").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("[\"anything\"]"))
|
||||
mvc.perform(post("/admin/catering/notes").with(csrf()).param("notes", "anything"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@@ -92,10 +95,12 @@ class AdminSecurityTest {
|
||||
void theAdminPageRedirectsABrowserToLogin() throws Exception {
|
||||
// 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 —
|
||||
// the platform answers */* (a fetch/XHR) with a bare 401 so the SPA can handle it — so this
|
||||
// must send a browser's Accept header to see the 302. (Verified against a running container.)
|
||||
// the platform answers */* with a bare 401, which is why the checks above see 401 and this one
|
||||
// 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"))
|
||||
.andExpect(status().is3xxRedirection());
|
||||
mvc.perform(get("/admin/catering").header("Accept", "text/html,application/xhtml+xml"))
|
||||
.andExpect(status().is3xxRedirection());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
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.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
|
||||
* 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
|
||||
* app on the platform was a React SPA. This one is server-rendered now — {@code /index.html} holds
|
||||
* nothing but the admin shell — so forwarding a mistyped URL there would answer with a blank page and a
|
||||
* 200 instead of the site's own 404. The contract's own test methods are package-private, so the
|
||||
* assertion cannot be overridden from here.
|
||||
* app on the platform was a React SPA. There is no SPA here at all now — no shell to forward to — so
|
||||
* that assertion describes an app this no longer is. The contract's own test methods are package-private,
|
||||
* so it cannot be overridden from here.
|
||||
*
|
||||
* <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).
|
||||
@@ -92,13 +90,12 @@ class PlatformContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unknown route is a 404, and only /admin serves the SPA shell")
|
||||
void routingIsServerSideExceptForTheAdmin() throws Exception {
|
||||
@DisplayName("routing is server-side: an unknown path is a 404, and so is /admin with no identity provider")
|
||||
void routingIsServerSide() throws Exception {
|
||||
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,
|
||||
// so the body is empty here by design — which also means this passes before the admin is built.
|
||||
mvc.perform(get("/admin"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(forwardedUrl("/index.html"));
|
||||
// No SECURITY_MODE here, so AdminController does not exist — "no Authentik configured" means "no
|
||||
// admin", and it 404s like any other unknown path rather than exposing catalogue writes.
|
||||
// AdminSecurityTest covers the other half: with OIDC on, /admin exists and needs a login.
|
||||
mvc.perform(get("/admin")).andExpect(status().isNotFound());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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