diff --git a/.gitignore b/.gitignore
index cf75f71..512f9ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/README.md b/README.md
index b5831dc..03db6ce 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/frontend/index.html b/frontend/index.html
deleted file mode 100644
index a494fc0..0000000
--- a/frontend/index.html
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
- The Vine — admin
-
-
-
-
-
-
diff --git a/frontend/package.json b/frontend/package.json
deleted file mode 100644
index 29174e2..0000000
--- a/frontend/package.json
+++ /dev/null
@@ -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"
- }
-}
diff --git a/frontend/site.css b/frontend/site.css
deleted file mode 100644
index e44b5d6..0000000
--- a/frontend/site.css
+++ /dev/null
@@ -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";
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
deleted file mode 100644
index fd1caa7..0000000
--- a/frontend/src/App.tsx
+++ /dev/null
@@ -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 = () => (
-
-
-
-);
-
-export default App;
diff --git a/frontend/src/components/admin/Catering.tsx b/frontend/src/components/admin/Catering.tsx
deleted file mode 100644
index 653ff78..0000000
--- a/frontend/src/components/admin/Catering.tsx
+++ /dev/null
@@ -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;
-}) => (
-
-
-
- {dirty && Not saved yet.}
- {(columns === 0 || draft.rows.length === 0) && !dirty && (
-
- Needs a column and a line before it shows on the page.
-
- )}
-
-
- >
- )}
-
- );
-};
-
-export default Catering;
diff --git a/frontend/src/components/admin/ui.tsx b/frontend/src/components/admin/ui.tsx
deleted file mode 100644
index 7cef8db..0000000
--- a/frontend/src/components/admin/ui.tsx
+++ /dev/null
@@ -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 }) => (
-
-);
-
-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(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;
-}
diff --git a/frontend/src/index.css b/frontend/src/index.css
deleted file mode 100644
index 40693f5..0000000
--- a/frontend/src/index.css
+++ /dev/null
@@ -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";
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
deleted file mode 100644
index 6e0ec11..0000000
--- a/frontend/src/lib/api.ts
+++ /dev/null
@@ -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(path: string): Promise {
- const res = await fetch(path, { headers: { Accept: 'application/json' } });
- if (!res.ok) throw new Error(`${path} responded ${res.status}`);
- return res.json() as Promise;
-}
-
-export const fetchProducts = (category?: string) =>
- get(category && category !== 'All' ? `/api/products?category=${encodeURIComponent(category)}` : '/api/products');
-
-export const fetchCategories = () => get('/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 {
- 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(path: string, method: string, body?: unknown, form?: FormData): Promise {
- 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('/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('/api/admin/products', 'POST', undefined, form);
-}
-
-export const describeProduct = (id: number, name: string, category: string) =>
- send(`/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(`/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(`/api/admin/products/${id}/photos`, 'PUT', keys);
-
-export const reorderProducts = (ids: number[]) =>
- send('/api/admin/products/order', 'PUT', { ids });
-
-// --- categories -------------------------------------------------------------
-
-export const adminCategories = () => get('/api/admin/categories');
-
-export const createCategory = (name: string) =>
- send('/api/admin/categories', 'POST', { name });
-
-export const renameCategory = (id: number, name: string) =>
- send(`/api/admin/categories/${id}`, 'PUT', { name });
-
-export const reorderCategories = (ids: number[]) =>
- send('/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('/api/catering');
-
-/** What the editor sees: the same tables, including any they haven't finished filling in. */
-export const adminCatering = () => get('/api/admin/catering');
-
-export const addCateringTable = (name: string) =>
- send('/api/admin/catering/packages', 'POST', { name });
-
-export const saveCateringTable = (id: number, table: CateringTableEdit) =>
- send(`/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('/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('/api/admin/catering/notes', 'PUT', notes);
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
deleted file mode 100644
index a74ce78..0000000
--- a/frontend/src/main.tsx
+++ /dev/null
@@ -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(
-
-
- ,
-);
diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx
deleted file mode 100644
index 9d1dcd1..0000000
--- a/frontend/src/pages/Admin.tsx
+++ /dev/null
@@ -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;
-}) => (
-