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

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

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

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

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

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

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

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