Archived
Merge the catalogue admin (your branch, reconciled onto main)
build-and-publish / build (push) Successful in 1m55s
build-and-publish / build (push) Successful in 1m55s
Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
This commit is contained in:
+4
-1
@@ -15,7 +15,10 @@ RUN --mount=type=secret,id=maven_user --mount=type=secret,id=maven_token \
|
||||
|
||||
# ---------- runtime ----------
|
||||
FROM eclipse-temurin:25-jre-alpine AS runtime
|
||||
RUN apk -U upgrade --no-cache && apk add --no-cache curl
|
||||
# libwebp-tools supplies cwebp, which ProductPhotoService shells out to when an editor uploads a
|
||||
# photo. Alpine's build is musl-native — the Java webp writers on Maven Central bundle glibc natives
|
||||
# that will not load here, and no pure-Java webp encoder exists.
|
||||
RUN apk -U upgrade --no-cache && apk add --no-cache curl libwebp-tools
|
||||
RUN addgroup -S spring && adduser -S -D -H -h /app -s /sbin/nologin -G spring spring
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -29,6 +29,25 @@ The SPA renders; it doesn't decide anything.
|
||||
- **Per-page metadata** — `PageMetaController` rewrites `<title>`/`<meta>`/OG tags per route. Next used
|
||||
to server-render these; a plain SPA would hand crawlers and link-preview scrapers one generic shell.
|
||||
|
||||
## /admin
|
||||
|
||||
The catalogue is editable from the site: add an item with a photo and a name, reorder it, rename or
|
||||
reorder the category filters. Nothing there needs a deploy or a migration — which is the point, since
|
||||
the person adding a cake is the person who baked it.
|
||||
|
||||
Photos are resized, stripped of EXIF, converted to webp and put in the bucket on upload
|
||||
(`ProductPhotoService`, using `cwebp` from `libwebp-tools` — the pure-Java encoders either can't write
|
||||
webp or ship glibc natives that don't run on Alpine).
|
||||
|
||||
**The admin only exists when `SECURITY_MODE=OIDC`.** `AdminProductController` and
|
||||
`AdminCategoryController` are `@ConditionalOnProperty` on it, so a deployment that forgets to configure
|
||||
Authentik gets 404s rather than catalogue writes open to the internet. `/admin` and `/api/admin/**` are
|
||||
both authenticated paths: a browser opening the page is sent to Authentik first, while `fetch` calls get
|
||||
a bare 401 to handle.
|
||||
|
||||
Known gap: `StorageService` has no delete, so removing a product or a photo leaves the object in the
|
||||
bucket. Harmless — nothing links to it — but it accumulates.
|
||||
|
||||
## Photos
|
||||
|
||||
Re-encoded to webp and uploaded to the bucket once (50 MB of originals → 14 MB), served with a
|
||||
@@ -63,6 +82,12 @@ mvn verify
|
||||
| `CONTACT_HUB_URL` | optional n8n webhook; best-effort, never blocks a submission |
|
||||
| `SITE_BASE_URL` | absolute base for `og:url` |
|
||||
| `VITE_ASSET_BASE` / `site.assets.base-url` | photo bucket |
|
||||
| `SECURITY_MODE` | `OIDC` turns on Authentik login **and brings `/admin` into existence**. Unset = brochure site, no admin |
|
||||
| `STORAGE_ENDPOINT` / `STORAGE_ACCESS_KEY` / `STORAGE_SECRET_KEY` / `STORAGE_BUCKET` | MinIO, for admin photo uploads. Blank endpoint leaves storage switched off |
|
||||
|
||||
With `SECURITY_MODE=OIDC` the app also needs the standard Spring OAuth2 client properties for the
|
||||
Authentik application — `SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_*` and
|
||||
`..._PROVIDER_*_ISSUER_URI`. The starter configures the filter chain, not the identity provider.
|
||||
|
||||
A missing `CONTACT_TO` **stops the app from starting**. That is deliberate: `application.yaml` maps it
|
||||
to `platform.contact.to`, and an unset variable leaves the property present-but-empty, which is enough
|
||||
|
||||
+30
-14
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { Outlet, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import HomePage from '@/pages/Home';
|
||||
@@ -7,9 +7,7 @@ import ProductsPage from '@/pages/Products';
|
||||
import HistoryPage from '@/pages/History';
|
||||
import ContactPage from '@/pages/Contact';
|
||||
import NotFoundPage from '@/pages/NotFound';
|
||||
import AdminPage from '@/pages/admin/AdminPage';
|
||||
import ProductEditorPage from '@/pages/admin/ProductEditorPage';
|
||||
import { AuthProvider } from '@/lib/auth';
|
||||
import AdminPage from '@/pages/Admin';
|
||||
|
||||
/**
|
||||
* Client-side navigation keeps the previous scroll position, which lands you halfway down a page you
|
||||
@@ -25,26 +23,44 @@ const ScrollToTop = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<AuthProvider>
|
||||
/** The shop front: the nav, the footer, and the pages a customer sees. */
|
||||
const PublicLayout = () => (
|
||||
<div className="min-h-screen bg-bakery-50 flex flex-col">
|
||||
<ScrollToTop />
|
||||
<Header />
|
||||
<main className="flex-grow">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* The admin sits outside the public chrome deliberately. It isn't a page you'd browse to — 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 AdminLayout = () => (
|
||||
<div className="min-h-screen bg-bakery-50">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
|
||||
const App = () => (
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/admin/products/new" element={<ProductEditorPage />} />
|
||||
<Route path="/admin/products/:id" element={<ProductEditorPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</AuthProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import Logo from './Logo';
|
||||
|
||||
const Header = () => {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Our Products', href: '/products' },
|
||||
{ label: 'Our Story', href: '/history' },
|
||||
{ label: 'Contact', href: '/contact' },
|
||||
];
|
||||
|
||||
const Header = () => {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// Close on navigation — without this the panel stays up over the page you just opened.
|
||||
useEffect(() => setIsMobileMenuOpen(false), [pathname]);
|
||||
|
||||
// Escape closes it, and the page behind it doesn't scroll while it's up.
|
||||
useEffect(() => {
|
||||
if (!isMobileMenuOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setIsMobileMenuOpen(false); };
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [isMobileMenuOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-40 bg-bakery-50/90 backdrop-blur border-b border-bakery-200">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between gap-2 h-20 md:h-24">
|
||||
@@ -31,11 +49,12 @@ const Header = () => {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
{/* Mobile menu button — the same control opens and closes, so the bar never
|
||||
disappears out from under your thumb. */}
|
||||
<button
|
||||
className="md:hidden p-2 shrink-0"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
onClick={() => setIsMobileMenuOpen((open) => !open)}
|
||||
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={isMobileMenuOpen}
|
||||
>
|
||||
<svg
|
||||
@@ -46,60 +65,53 @@ const Header = () => {
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
{isMobileMenuOpen ? <path d="M6 18L18 6M6 6l12 12" /> : <path d="M4 6h16M4 12h16M4 18h16" />}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Navigation — must UNMOUNT when closed. A panel parked off-screen
|
||||
at translate-x-full still extends the scrollable area, which is what let
|
||||
you scroll sideways and find the menu. */}
|
||||
{/* Mobile navigation. Three things here are load-bearing:
|
||||
|
||||
It lives OUTSIDE <header>. The header carries `backdrop-blur`, and a backdrop-filter
|
||||
makes an element a containing block for fixed-position descendants — so a `fixed` panel
|
||||
nested inside it resolves against the 80px header box, not the viewport, and gets
|
||||
clipped to a sliver.
|
||||
|
||||
It starts BELOW the bar (`top-20`) instead of covering it, so the logo and the toggle
|
||||
stay put and the panel needs no second copy of either. One logo, one position, every
|
||||
breakpoint.
|
||||
|
||||
It must UNMOUNT when closed: a panel parked off-screen still extends the scrollable
|
||||
area, which is what used to let you scroll sideways and find the menu. */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'tween', duration: 0.25, ease: 'easeOut' }}
|
||||
className="md:hidden fixed inset-0 bg-bakery-50 z-50"
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="md:hidden fixed inset-x-0 top-20 bottom-0 z-30 bg-bakery-50"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center gap-2 mb-8 h-16">
|
||||
<Logo className="text-bakery-800" />
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="p-2 shrink-0"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6 text-bakery-700"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex flex-col">
|
||||
<nav className="container mx-auto px-4 flex flex-col">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className="text-bakery-800 hover:text-bakery-600 transition py-4 text-lg"
|
||||
className="text-bakery-800 hover:text-bakery-600 transition py-4 text-lg border-b border-bakery-100"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,31 +5,58 @@ interface ProductGalleryProps {
|
||||
alt: string;
|
||||
}
|
||||
|
||||
const Chevron = ({ direction }: { direction: 'left' | 'right' }) => (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={direction === 'left' ? 'M15 18l-6-6 6-6' : 'M9 18l6-6-6-6'} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* The square photo on a product card, with arrows when there's more than one shot.
|
||||
* The square photo on a product card, with arrows and dots when there's more than one shot.
|
||||
*
|
||||
* Replaces react-awesome-slider, which hasn't been published since 2020 and pins peer deps to
|
||||
* React 16 — the same job in a fraction of the code, and one less unmaintained dependency in a
|
||||
* build we gate on CVEs. Behaviour is what the old cards did: one image at a time, square crop,
|
||||
* arrows only when they'd do something — plus swipe and keyboard, which the old slider had on touch
|
||||
* devices and the first version of this did not.
|
||||
* arrows only when they'd do something.
|
||||
*/
|
||||
|
||||
/** Past this many pixels a horizontal drag counts as a swipe rather than a tap or a page scroll. */
|
||||
const SWIPE_THRESHOLD = 40;
|
||||
|
||||
const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
// Filtering swaps the product under a reused component instance, so a stale index can point
|
||||
// past the new list — every frame then renders at opacity-0 and the card goes blank. Reset
|
||||
// during render (the React-sanctioned way to derive state from props) rather than in an effect,
|
||||
// so the correct frame paints on the first pass instead of flashing an empty square.
|
||||
const [renderedFor, setRenderedFor] = useState(images);
|
||||
if (renderedFor !== images) {
|
||||
setRenderedFor(images);
|
||||
setIndex(0);
|
||||
}
|
||||
|
||||
const many = images.length > 1;
|
||||
const touchStart = useRef<{ x: number; y: number } | null>(null);
|
||||
const active = index < images.length ? index : 0;
|
||||
|
||||
const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length);
|
||||
|
||||
// Swipe on touch devices and arrow keys — the react-awesome-slider this replaced had swipe, and
|
||||
// the products page is browsed mostly on phones. Vertical drags are left alone so the page still
|
||||
// scrolls through the card.
|
||||
const touchStart = useRef<{ x: number; y: number } | null>(null);
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
const t = e.touches[0];
|
||||
touchStart.current = { x: t.clientX, y: t.clientY };
|
||||
};
|
||||
|
||||
const onTouchEnd = (e: React.TouchEvent) => {
|
||||
const start = touchStart.current;
|
||||
touchStart.current = null;
|
||||
@@ -37,11 +64,13 @@ const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
|
||||
const t = e.changedTouches[0];
|
||||
const dx = t.clientX - start.x;
|
||||
const dy = t.clientY - start.y;
|
||||
// Ignore anything more vertical than horizontal — that is the page being scrolled, not a swipe.
|
||||
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) <= Math.abs(dy)) return;
|
||||
step(dx < 0 ? 1 : -1);
|
||||
};
|
||||
|
||||
const arrowClass =
|
||||
'absolute top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-square bg-bakery-100"
|
||||
@@ -64,10 +93,10 @@ const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
|
||||
alt={i === 0 ? alt : ''}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
|
||||
i === index ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 motion-reduce:transition-none ${
|
||||
i === active ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
aria-hidden={i === index ? undefined : true}
|
||||
aria-hidden={i === active ? undefined : true}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -77,26 +106,30 @@ const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
|
||||
type="button"
|
||||
onClick={() => step(-1)}
|
||||
aria-label={`Previous photo of ${alt}`}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white text-2xl leading-none backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||
className={`${arrowClass} left-2`}
|
||||
>
|
||||
<span aria-hidden="true">{'<'}</span>
|
||||
<Chevron direction="left" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => step(1)}
|
||||
aria-label={`Next photo of ${alt}`}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white text-2xl leading-none backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||
className={`${arrowClass} right-2`}
|
||||
>
|
||||
<span aria-hidden="true">{'>'}</span>
|
||||
<Chevron direction="right" />
|
||||
</button>
|
||||
{/* Which of how many. The old slider ran with bullets off, but once a card can be swiped
|
||||
there is otherwise nothing to say it holds more than one photo. */}
|
||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 flex gap-1.5" aria-hidden="true">
|
||||
|
||||
{/* Dots: how many photos there are, and which one you're on. */}
|
||||
<div className="absolute inset-x-0 bottom-3 flex justify-center gap-1.5">
|
||||
{images.map((src, i) => (
|
||||
<span
|
||||
<button
|
||||
key={src}
|
||||
className={`h-1.5 w-1.5 rounded-full transition ${
|
||||
i === index ? 'bg-white' : 'bg-white/40'
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
aria-label={`Show photo ${i + 1} of ${images.length} of ${alt}`}
|
||||
aria-current={i === active ? 'true' : undefined}
|
||||
className={`h-1.5 rounded-full shadow-xs transition-all motion-reduce:transition-none focus:outline-none focus-visible:ring-2 focus-visible:ring-white ${
|
||||
i === active ? 'w-4 bg-white' : 'w-1.5 bg-white/60 hover:bg-white/80'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
+86
-73
@@ -11,14 +11,19 @@ export interface Product {
|
||||
images: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring Security protects every mutating request with a CSRF token, and the platform's security
|
||||
* starter writes it to a readable XSRF-TOKEN cookie. Without this header a POST is rejected 403 —
|
||||
* including the public contact form, which is not obvious until the form stops working.
|
||||
*/
|
||||
function csrfHeaders(): Record<string, string> {
|
||||
const token = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='))?.split('=')[1];
|
||||
return token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {};
|
||||
/** What the admin screens get back: the public shape plus where it sits in the order. */
|
||||
export interface AdminProduct extends Product {
|
||||
position: number;
|
||||
/** The same photos as `images`, in the same order — these are what arrangePhotos names them by. */
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
export interface AdminCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
position: number;
|
||||
/** How many products are filed under it — deleting one that's in use is refused. */
|
||||
used: number;
|
||||
}
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
@@ -32,82 +37,90 @@ export const fetchProducts = (category?: string) =>
|
||||
|
||||
export const fetchCategories = () => get<string[]>('/api/categories');
|
||||
|
||||
// ---- who is signed in (public: the SPA asks on every page load) ----
|
||||
export interface Me { authenticated: boolean; admin: boolean; name: string | null }
|
||||
export const fetchMe = () => get<Me>('/api/me');
|
||||
|
||||
// ---- admin (everything below needs an Authentik login) ----
|
||||
export interface AdminProduct {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
position: number;
|
||||
imageKeys: string[];
|
||||
imageUrls: string[];
|
||||
}
|
||||
export interface AdminEnquiry {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
message: string;
|
||||
delivered: boolean;
|
||||
receivedAt: string;
|
||||
}
|
||||
export interface ProductForm {
|
||||
name: string;
|
||||
category: string;
|
||||
position: number | null;
|
||||
imageKeys: string[];
|
||||
/**
|
||||
* Spring hands the SPA a CSRF token in a cookie and wants it echoed on anything that writes. Read
|
||||
* per request rather than cached: it rotates on sign-in, and a stale token fails exactly like a
|
||||
* missing one. Returns nothing when security is off, which is why the contact form still posts
|
||||
* happily on a deployment with no identity provider.
|
||||
*/
|
||||
export function csrfHeader(): Record<string, string> {
|
||||
const token = document.cookie
|
||||
.split('; ')
|
||||
.find((c) => c.startsWith('XSRF-TOKEN='))
|
||||
?.slice('XSRF-TOKEN='.length);
|
||||
return token ? { 'X-XSRF-TOKEN': decodeURIComponent(token) } : {};
|
||||
}
|
||||
|
||||
async function send<T>(path: string, method: string, body?: unknown): Promise<T> {
|
||||
/**
|
||||
* Every admin write funnels through here so one place understands the server's failure shapes: a
|
||||
* 401/403 means the session lapsed (the OIDC chain answers /api with a status rather than bouncing
|
||||
* you to a login page), and anything else carries a ProblemDetail whose `detail` is the sentence
|
||||
* the server wants the editor to read.
|
||||
*/
|
||||
async function send<T>(path: string, method: string, body?: unknown, form?: FormData): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...csrfHeaders() },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
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) {
|
||||
// The backend puts a human-readable reason in `detail`; show that rather than a status code.
|
||||
let detail = `${method} ${path} responded ${res.status}`;
|
||||
try { detail = (await res.json()).detail ?? detail; } catch { /* not JSON */ }
|
||||
throw new Error(detail);
|
||||
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 as T) : (res.json() as Promise<T>);
|
||||
return (res.status === 204 ? undefined : await res.json()) as T;
|
||||
}
|
||||
|
||||
export const fetchAdminProducts = () => get<AdminProduct[]>('/api/admin/products');
|
||||
export const createProduct = (f: ProductForm) => send<AdminProduct>('/api/admin/products', 'POST', f);
|
||||
export const updateProduct = (id: number, f: ProductForm) =>
|
||||
send<AdminProduct>(`/api/admin/products/${id}`, 'PUT', f);
|
||||
export const deleteProduct = (id: number) => send<void>(`/api/admin/products/${id}`, 'DELETE');
|
||||
export const fetchEnquiries = () => get<AdminEnquiry[]>('/api/admin/enquiries');
|
||||
// --- products ---------------------------------------------------------------
|
||||
|
||||
export interface UploadTarget { key: string; uploadUrl: string; publicUrl: string }
|
||||
export const adminProducts = () => get<AdminProduct[]>('/api/admin/products');
|
||||
|
||||
/** Presign, then PUT the file straight to the bucket — the photo never passes through the app. */
|
||||
export async function uploadPhoto(file: File): Promise<UploadTarget> {
|
||||
const target = await send<UploadTarget>(
|
||||
`/api/admin/images/presign-upload?filename=${encodeURIComponent(file.name)}`
|
||||
+ `&contentType=${encodeURIComponent(file.type || 'application/octet-stream')}`,
|
||||
'POST');
|
||||
// Straight to the bucket, so no CSRF header here — it is a different origin and a presigned URL.
|
||||
const put = await fetch(target.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||
body: file,
|
||||
});
|
||||
if (!put.ok) throw new Error(`the bucket rejected the upload (${put.status})`);
|
||||
return target;
|
||||
export function createProduct(name: string, category: string, photos: File[]) {
|
||||
const form = new FormData();
|
||||
form.append('name', name);
|
||||
form.append('category', category);
|
||||
photos.forEach((p) => form.append('photos', p));
|
||||
return send<AdminProduct>('/api/admin/products', 'POST', undefined, form);
|
||||
}
|
||||
|
||||
/** The public contact form. Mutating, so it needs the CSRF token too. */
|
||||
export async function submitContact(input: { name: string; email: string; message: string }) {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...csrfHeaders() },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Could not send the message.');
|
||||
return data;
|
||||
export const describeProduct = (id: number, name: string, category: string) =>
|
||||
send<AdminProduct>(`/api/admin/products/${id}`, 'PUT', { name, category });
|
||||
|
||||
export const deleteProduct = (id: number) =>
|
||||
send<{ ok: boolean }>(`/api/admin/products/${id}`, 'DELETE');
|
||||
|
||||
export function addPhotos(id: number, photos: File[]) {
|
||||
const form = new FormData();
|
||||
photos.forEach((p) => form.append('photos', p));
|
||||
return send<AdminProduct>(`/api/admin/products/${id}/photos`, 'POST', undefined, form);
|
||||
}
|
||||
|
||||
/** The full arrangement the editor is looking at — removing a photo is just an omission. */
|
||||
export const arrangePhotos = (id: number, keys: string[]) =>
|
||||
send<AdminProduct>(`/api/admin/products/${id}/photos`, 'PUT', keys);
|
||||
|
||||
export const reorderProducts = (ids: number[]) =>
|
||||
send<AdminProduct[]>('/api/admin/products/order', 'PUT', { ids });
|
||||
|
||||
// --- categories -------------------------------------------------------------
|
||||
|
||||
export const adminCategories = () => get<AdminCategory[]>('/api/admin/categories');
|
||||
|
||||
export const createCategory = (name: string) =>
|
||||
send<AdminCategory>('/api/admin/categories', 'POST', { name });
|
||||
|
||||
export const renameCategory = (id: number, name: string) =>
|
||||
send<AdminCategory>(`/api/admin/categories/${id}`, 'PUT', { name });
|
||||
|
||||
export const reorderCategories = (ids: number[]) =>
|
||||
send<AdminCategory[]>('/api/admin/categories/order', 'PUT', { ids });
|
||||
|
||||
export const deleteCategory = (id: number) =>
|
||||
send<{ ok: boolean }>(`/api/admin/categories/${id}`, 'DELETE');
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { fetchMe, type Me } from './api';
|
||||
|
||||
const ANON: Me = { authenticated: false, admin: false, name: null };
|
||||
|
||||
const AuthContext = createContext<{ me: Me; loading: boolean }>({ me: ANON, loading: true });
|
||||
|
||||
/**
|
||||
* Resolves the signed-in user from the PUBLIC /api/me.
|
||||
*
|
||||
* It has to be public: this runs on every page load, and if it required a login every anonymous
|
||||
* visitor would be bounced to Authentik just to read the menu.
|
||||
*/
|
||||
/** Where the reader was when they clicked Sign in, so they can be put back afterwards. */
|
||||
const RETURN_TO = 'vine:post-login-path';
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<{ me: Me; loading: boolean }>({ me: ANON, loading: true });
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchMe()
|
||||
.then((me) => {
|
||||
setState({ me, loading: false });
|
||||
if (!me.authenticated) return;
|
||||
// Spring only remembers where you were if you were BOUNCED off a protected page. Every route
|
||||
// here is public — the SPA sends you to the identity provider itself — so there is nothing
|
||||
// saved and login lands on "/". Put the reader back where they started.
|
||||
const back = sessionStorage.getItem(RETURN_TO);
|
||||
if (back) {
|
||||
sessionStorage.removeItem(RETURN_TO);
|
||||
if (back !== window.location.pathname + window.location.search) {
|
||||
navigate(back, { replace: true });
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => setState({ me: ANON, loading: false }));
|
||||
}, [navigate]);
|
||||
|
||||
return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
/** Full-page navigation, not fetch: the OIDC handshake is a redirect chain the browser must follow. */
|
||||
export const signIn = () => {
|
||||
// sessionStorage rather than a query parameter: it survives the whole redirect chain, stays in this
|
||||
// tab, and never becomes something an attacker can point at another site.
|
||||
sessionStorage.setItem(RETURN_TO, window.location.pathname + window.location.search);
|
||||
window.location.href = '/oauth2/authorization/authentik';
|
||||
};
|
||||
@@ -0,0 +1,716 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// --- little pieces ----------------------------------------------------------
|
||||
|
||||
const Icon = ({ d, className = '' }: { d: string; className?: string }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`w-4 h-4 ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={d} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ARROW_UP = 'M12 19V5M5 12l7-7 7 7';
|
||||
const ARROW_DOWN = 'M12 5v14M19 12l-7 7-7-7';
|
||||
const ARROW_LEFT = 'M19 12H5M12 19l-7-7 7-7';
|
||||
const ARROW_RIGHT = 'M5 12h14M12 5l7 7-7 7';
|
||||
const TRASH = 'M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6';
|
||||
const PLUS = 'M12 5v14M5 12h14';
|
||||
const CHECK = 'M20 6L9 17l-5-5';
|
||||
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';
|
||||
const primary = `${button} bg-bakery-600 text-white hover:bg-bakery-700`;
|
||||
const secondary = `${button} border border-bakery-300 text-bakery-800 hover:bg-bakery-100`;
|
||||
const danger = `${button} text-red-700 hover:bg-red-50`;
|
||||
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';
|
||||
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. */
|
||||
function shift<T>(items: T[], index: number, delta: number): T[] {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= items.length) return items;
|
||||
const next = [...items];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
}
|
||||
|
||||
// --- photos -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The photos on one item. Order matters — the first is the one the products page leads with — so
|
||||
* arranging is left/right rather than a drag target, which is far easier to hit on a phone.
|
||||
*/
|
||||
const Photos = ({
|
||||
product,
|
||||
onArrange,
|
||||
busy,
|
||||
}: {
|
||||
product: AdminProduct;
|
||||
onArrange: (keys: string[]) => void;
|
||||
busy: boolean;
|
||||
}) => (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.images.map((url, i) => (
|
||||
<figure key={product.keys[i] ?? url} className="w-28">
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-28 h-28 rounded-md object-cover border border-bakery-200 bg-bakery-100"
|
||||
/>
|
||||
<figcaption className="mt-1 flex items-center justify-between gap-1">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={iconButton}
|
||||
disabled={busy || i === 0}
|
||||
onClick={() => onArrange(shift(product.keys, i, -1))}
|
||||
aria-label="Move photo earlier"
|
||||
>
|
||||
<Icon d={ARROW_LEFT} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconButton}
|
||||
disabled={busy || i === product.images.length - 1}
|
||||
onClick={() => onArrange(shift(product.keys, i, 1))}
|
||||
aria-label="Move photo later"
|
||||
>
|
||||
<Icon d={ARROW_RIGHT} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={iconButton}
|
||||
// The server refuses an empty arrangement; saying so up front beats an error message.
|
||||
disabled={busy || product.images.length === 1}
|
||||
onClick={() => onArrange(product.keys.filter((_, at) => at !== i))}
|
||||
aria-label="Remove photo"
|
||||
title={product.images.length === 1 ? 'An item needs at least one photo' : 'Remove photo'}
|
||||
>
|
||||
<Icon d={TRASH} />
|
||||
</button>
|
||||
</figcaption>
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** A file picker styled as a button, resetting itself so the same file can be chosen twice. */
|
||||
const PhotoPicker = ({
|
||||
label,
|
||||
onPick,
|
||||
disabled,
|
||||
className = secondary,
|
||||
}: {
|
||||
label: string;
|
||||
onPick: (files: File[]) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
e.target.value = '';
|
||||
if (files.length) onPick(files);
|
||||
}}
|
||||
/>
|
||||
<button type="button" className={className} disabled={disabled} onClick={() => input.current?.click()}>
|
||||
<Icon d={PLUS} />
|
||||
{label}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// --- one item ---------------------------------------------------------------
|
||||
|
||||
const ProductCard = ({
|
||||
product,
|
||||
categories,
|
||||
first,
|
||||
last,
|
||||
onChange,
|
||||
onMove,
|
||||
onDelete,
|
||||
onError,
|
||||
}: {
|
||||
product: AdminProduct;
|
||||
categories: string[];
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
onChange: (updated: AdminProduct) => void;
|
||||
onMove: (delta: number) => void;
|
||||
onDelete: () => void;
|
||||
onError: (message: string) => void;
|
||||
}) => {
|
||||
const [name, setName] = useState(product.name);
|
||||
const [category, setCategory] = useState(product.category);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// A reorder or an upload re-fetches this product; the fields should follow unless they're being
|
||||
// edited, which is what the dirty check below decides.
|
||||
const dirty = name !== product.name || category !== product.category;
|
||||
const [synced, setSynced] = useState(product);
|
||||
if (synced !== product) {
|
||||
setSynced(product);
|
||||
if (!dirty) {
|
||||
setName(product.name);
|
||||
setCategory(product.category);
|
||||
}
|
||||
}
|
||||
|
||||
const run = async (work: () => Promise<AdminProduct>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
onChange(await work());
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : 'That did not save.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<div className="flex sm:flex-col gap-1 sm:pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className={iconButton}
|
||||
disabled={first}
|
||||
onClick={() => onMove(-1)}
|
||||
aria-label={`Move ${product.name} up`}
|
||||
>
|
||||
<Icon d={ARROW_UP} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconButton}
|
||||
disabled={last}
|
||||
onClick={() => onMove(1)}
|
||||
aria-label={`Move ${product.name} down`}
|
||||
>
|
||||
<Icon d={ARROW_DOWN} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_12rem]">
|
||||
<label className="block">
|
||||
<span className="sr-only">Name</span>
|
||||
<input
|
||||
className={field}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="sr-only">Category</span>
|
||||
<select className={field} value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
{/* A product can sit in a category nobody defined; don't silently retype it. */}
|
||||
{!categories.includes(category) && <option value={category}>{category}</option>}
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Photos
|
||||
product={product}
|
||||
busy={busy}
|
||||
onArrange={(keys) => run(() => arrangePhotos(product.id, keys))}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<PhotoPicker
|
||||
label="Add photos"
|
||||
disabled={busy}
|
||||
onPick={(files) => run(() => addPhotos(product.id, files))}
|
||||
/>
|
||||
{dirty && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={primary}
|
||||
disabled={busy}
|
||||
onClick={() => run(() => describeProduct(product.id, name.trim(), category))}
|
||||
>
|
||||
<Icon d={CHECK} />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={secondary}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setName(product.name);
|
||||
setCategory(product.category);
|
||||
}}
|
||||
>
|
||||
<Icon d={X} />
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className={`${danger} ml-auto`} disabled={busy} onClick={onDelete}>
|
||||
<Icon d={TRASH} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{busy && <p className="text-sm text-bakery-600">Working…</p>}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
// --- adding an item ---------------------------------------------------------
|
||||
|
||||
const NewItem = ({
|
||||
categories,
|
||||
onAdded,
|
||||
onError,
|
||||
}: {
|
||||
categories: string[];
|
||||
onAdded: (product: AdminProduct) => void;
|
||||
onError: (message: string) => void;
|
||||
}) => {
|
||||
const [name, setName] = useState('');
|
||||
const [category, setCategory] = useState(categories[0] ?? '');
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// The category list arrives after the first render, so the default has to catch up once.
|
||||
useEffect(() => {
|
||||
setCategory((c) => (c || categories[0] || ''));
|
||||
}, [categories]);
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
onAdded(await createProduct(name.trim(), category, files));
|
||||
setName('');
|
||||
setFiles([]);
|
||||
} catch (e) {
|
||||
onError(e instanceof Error ? e.message : 'That did not save.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
|
||||
<h2 className="font-adbhashitha text-xl text-bakery-800">Add something new</h2>
|
||||
<p className="mt-1 text-sm text-bakery-600">New items go to the top of the products page.</p>
|
||||
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-[1fr_12rem]">
|
||||
<input
|
||||
className={field}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="What is it? e.g. Chocolate drip cake"
|
||||
/>
|
||||
<select className={field} value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{files.map((file, i) => (
|
||||
<div key={`${file.name}-${i}`} className="relative">
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt=""
|
||||
className="w-20 h-20 rounded-md object-cover border border-bakery-200"
|
||||
// Revoking once it's painted keeps the preview from holding the file in memory.
|
||||
onLoad={(e) => URL.revokeObjectURL(e.currentTarget.src)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-white border border-bakery-300 text-bakery-700 flex items-center justify-center"
|
||||
onClick={() => setFiles(files.filter((_, at) => at !== i))}
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<Icon d={X} className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<PhotoPicker
|
||||
label={files.length ? 'Add more photos' : 'Choose photos'}
|
||||
disabled={busy}
|
||||
onPick={(picked) => setFiles((current) => [...current, ...picked])}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={primary}
|
||||
disabled={busy || !name.trim() || !category || files.length === 0}
|
||||
onClick={submit}
|
||||
>
|
||||
<Icon d={PLUS} />
|
||||
{busy ? 'Uploading…' : 'Add to the page'}
|
||||
</button>
|
||||
{busy && <span className="text-sm text-bakery-600">Photos can take a few seconds each.</span>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
// --- categories -------------------------------------------------------------
|
||||
|
||||
const Categories = ({
|
||||
categories,
|
||||
setCategories,
|
||||
onError,
|
||||
onChanged,
|
||||
}: {
|
||||
categories: AdminCategory[];
|
||||
setCategories: (next: AdminCategory[]) => void;
|
||||
onError: (message: string) => void;
|
||||
onChanged: () => void;
|
||||
}) => {
|
||||
const [fresh, setFresh] = useState('');
|
||||
const [editing, setEditing] = useState<number | null>(null);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const guard = async (work: () => Promise<unknown>, optimistic?: AdminCategory[]) => {
|
||||
const before = categories;
|
||||
if (optimistic) setCategories(optimistic);
|
||||
setBusy(true);
|
||||
try {
|
||||
await work();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
if (optimistic) setCategories(before);
|
||||
onError(e instanceof Error ? e.message : 'That did not save.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-bakery-200 bg-white p-4 shadow-sm">
|
||||
<h2 className="font-adbhashitha text-xl text-bakery-800">Categories</h2>
|
||||
<p className="mt-1 text-sm text-bakery-600">
|
||||
These are the filter buttons on the products page, in this order. Renaming one moves
|
||||
everything filed under it too.
|
||||
</p>
|
||||
|
||||
<ul className="mt-3 divide-y divide-bakery-100">
|
||||
{categories.map((category, i) => (
|
||||
<li key={category.id} className="flex items-center gap-2 py-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={iconButton}
|
||||
disabled={busy || i === 0}
|
||||
onClick={() =>
|
||||
guard(
|
||||
() => reorderCategories(shift(categories, i, -1).map((c) => c.id)),
|
||||
shift(categories, i, -1),
|
||||
)
|
||||
}
|
||||
aria-label={`Move ${category.name} up`}
|
||||
>
|
||||
<Icon d={ARROW_UP} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconButton}
|
||||
disabled={busy || i === categories.length - 1}
|
||||
onClick={() =>
|
||||
guard(
|
||||
() => reorderCategories(shift(categories, i, 1).map((c) => c.id)),
|
||||
shift(categories, i, 1),
|
||||
)
|
||||
}
|
||||
aria-label={`Move ${category.name} down`}
|
||||
>
|
||||
<Icon d={ARROW_DOWN} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editing === category.id ? (
|
||||
<>
|
||||
<input
|
||||
className={field}
|
||||
value={draft}
|
||||
autoFocus
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Escape' && setEditing(null)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={primary}
|
||||
disabled={busy || !draft.trim()}
|
||||
onClick={() =>
|
||||
guard(async () => {
|
||||
await renameCategory(category.id, draft.trim());
|
||||
setEditing(null);
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon d={CHECK} />
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className={secondary} onClick={() => setEditing(null)}>
|
||||
<Icon d={X} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-bakery-900">{category.name}</span>
|
||||
<span className="text-sm text-bakery-500">
|
||||
{category.used} item{category.used === 1 ? '' : 's'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={secondary}
|
||||
onClick={() => {
|
||||
setEditing(category.id);
|
||||
setDraft(category.name);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={danger}
|
||||
disabled={busy || category.used > 0}
|
||||
title={category.used > 0 ? 'Move its items somewhere else first' : 'Delete'}
|
||||
onClick={() =>
|
||||
guard(
|
||||
() => deleteCategory(category.id),
|
||||
categories.filter((c) => c.id !== category.id),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon d={TRASH} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
className={field}
|
||||
value={fresh}
|
||||
onChange={(e) => setFresh(e.target.value)}
|
||||
placeholder="New category"
|
||||
onKeyDown={(e) => e.key === 'Enter' && fresh.trim() && guard(async () => {
|
||||
await createCategory(fresh.trim());
|
||||
setFresh('');
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={primary}
|
||||
disabled={busy || !fresh.trim()}
|
||||
onClick={() =>
|
||||
guard(async () => {
|
||||
await createCategory(fresh.trim());
|
||||
setFresh('');
|
||||
})
|
||||
}
|
||||
>
|
||||
<Icon d={PLUS} />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
// --- the page ---------------------------------------------------------------
|
||||
|
||||
const AdminPage = () => {
|
||||
const [products, setProducts] = useState<AdminProduct[] | null>(null);
|
||||
const [categories, setCategories] = useState<AdminCategory[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [items, cats] = await Promise.all([adminProducts(), adminCategories()]);
|
||||
setProducts(items);
|
||||
setCategories(cats);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not load the catalogue.');
|
||||
setProducts([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const names = categories.map((c) => c.name);
|
||||
|
||||
/** Reorder and delete both apply on screen first — the point of this page is that it keeps up. */
|
||||
const settle = async (optimistic: AdminProduct[], work: () => Promise<unknown>) => {
|
||||
const before = products ?? [];
|
||||
setProducts(optimistic);
|
||||
try {
|
||||
await work();
|
||||
} catch (e) {
|
||||
setProducts(before);
|
||||
setError(e instanceof Error ? e.message : 'That did not save.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-6">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-lejour text-4xl text-bakery-700">The Vine</h1>
|
||||
<p className="text-bakery-600">Everything on the products page lives here.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a href="/products" className={secondary}>
|
||||
View the page
|
||||
</a>
|
||||
{/* A real form post: the platform's logout expects one, and it also ends the Authentik session. */}
|
||||
<form method="post" action="/logout">
|
||||
<button type="submit" className={secondary}>
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 flex items-start gap-3 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800"
|
||||
>
|
||||
<span className="flex-1">{error}</span>
|
||||
<button type="button" onClick={() => setError('')} aria-label="Dismiss">
|
||||
<Icon d={X} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{products === null ? (
|
||||
<p className="mt-10 text-bakery-600">Loading…</p>
|
||||
) : (
|
||||
<div className="mt-6 space-y-6">
|
||||
<Categories
|
||||
categories={categories}
|
||||
setCategories={setCategories}
|
||||
onError={setError}
|
||||
// A rename rewrites the products filed under it, so the list has to come back fresh.
|
||||
onChanged={() => void load()}
|
||||
/>
|
||||
|
||||
<NewItem
|
||||
categories={names}
|
||||
onError={setError}
|
||||
onAdded={(product) => setProducts([product, ...products])}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h2 className="font-adbhashitha text-xl text-bakery-800">
|
||||
On the page ({products.length})
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{products.map((product, i) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
categories={names}
|
||||
first={i === 0}
|
||||
last={i === products.length - 1}
|
||||
onError={setError}
|
||||
onChange={(updated) =>
|
||||
setProducts(products.map((p) => (p.id === updated.id ? updated : p)))
|
||||
}
|
||||
onMove={(delta) => {
|
||||
const moved = shift(products, i, delta);
|
||||
void settle(moved, () => reorderProducts(moved.map((p) => p.id)));
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (!confirm(`Remove ${product.name} from the products page?`)) return;
|
||||
void settle(
|
||||
products.filter((p) => p.id !== product.id),
|
||||
() => deleteProduct(product.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{products.length === 0 && (
|
||||
<p className="mt-3 text-bakery-600">Nothing here yet — add something above.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPage;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { submitContact } from '@/lib/api';
|
||||
import { csrfHeader } from '@/lib/api';
|
||||
|
||||
type Status = 'idle' | 'sending' | 'sent' | 'error';
|
||||
|
||||
@@ -24,7 +24,16 @@ const ContactPage = () => {
|
||||
setStatus('sending');
|
||||
setError('');
|
||||
try {
|
||||
await submitContact(formData);
|
||||
// csrfHeader() is empty unless security is switched on, so this posts the same as it always
|
||||
// has on a deployment with no identity provider — and keeps working once one is configured,
|
||||
// where an unaccompanied POST would otherwise be rejected.
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...csrfHeader() },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Could not send the message.');
|
||||
setStatus('sent');
|
||||
setFormData({ name: '', email: '', message: '' });
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { fetchCategories, fetchProducts, type Product } from '@/lib/api';
|
||||
import ProductGallery from '@/components/ProductGallery';
|
||||
|
||||
@@ -38,10 +37,9 @@ const ProductsPage = () => {
|
||||
{/* Categories */}
|
||||
<div className="flex flex-wrap justify-center gap-4 mb-12">
|
||||
{categories.map((category) => (
|
||||
<motion.button
|
||||
<button
|
||||
key={category}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors ${
|
||||
selectedCategory === category
|
||||
@@ -50,7 +48,7 @@ const ProductsPage = () => {
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</motion.button>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -61,20 +59,15 @@ const ProductsPage = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Products Grid */}
|
||||
<motion.div
|
||||
layout
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{/* Products Grid — deliberately unanimated. Filtering used to run a `layout` reflow plus
|
||||
an enter/exit fade on every card, which on a 40-card grid reads as the page lurching
|
||||
rather than responding. Swapping the list outright is instant, and the only motion
|
||||
left is the shadow on hover. */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{products.map((product) => (
|
||||
<motion.div
|
||||
<div
|
||||
key={product.id}
|
||||
layout
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="group bg-white rounded-3xl overflow-hidden shadow-xs hover:shadow-lg hover:-translate-y-1 transition duration-300"
|
||||
className="group bg-white rounded-3xl overflow-hidden shadow-xs transition-shadow duration-300 hover:shadow-lg"
|
||||
>
|
||||
{/* Image Container */}
|
||||
<div className="relative w-full overflow-hidden">
|
||||
@@ -90,10 +83,9 @@ const ProductsPage = () => {
|
||||
{product.category}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
deleteProduct, fetchAdminProducts, fetchEnquiries,
|
||||
type AdminEnquiry, type AdminProduct,
|
||||
} from '@/lib/api';
|
||||
import { useAuth, signIn } from '@/lib/auth';
|
||||
|
||||
type Tab = 'products' | 'enquiries';
|
||||
|
||||
export default function AdminPage() {
|
||||
const { me, loading } = useAuth();
|
||||
const [tab, setTab] = useState<Tab>('products');
|
||||
const [products, setProducts] = useState<AdminProduct[]>([]);
|
||||
const [enquiries, setEnquiries] = useState<AdminEnquiry[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = () => {
|
||||
// Only ask for admin data once we know there is a session; otherwise every anonymous visitor
|
||||
// who guesses this URL gets bounced to the identity provider.
|
||||
if (!me.admin) return;
|
||||
fetchAdminProducts().then(setProducts).catch((e) => setError(String(e.message ?? e)));
|
||||
fetchEnquiries().then(setEnquiries).catch((e) => setError(String(e.message ?? e)));
|
||||
};
|
||||
|
||||
useEffect(load, [me.admin]);
|
||||
|
||||
if (loading) {
|
||||
return <p className="container mx-auto px-4 py-20 text-bakery-700">Loading…</p>;
|
||||
}
|
||||
|
||||
if (!me.admin) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-20 text-center">
|
||||
<h1 className="font-adbhashitha text-3xl text-bakery-900 mb-6">Staff only</h1>
|
||||
<button
|
||||
onClick={signIn}
|
||||
className="px-8 py-3.5 bg-bakery-600 text-white rounded-full tracking-wide hover:bg-bakery-700 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function remove(p: AdminProduct) {
|
||||
if (!window.confirm(`Remove “${p.name}” from the menu? The photos stay in storage.`)) return;
|
||||
try {
|
||||
await deleteProduct(p.id);
|
||||
setProducts((list) => list.filter((x) => x.id !== p.id));
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-4 mb-8">
|
||||
<h1 className="font-adbhashitha text-4xl text-bakery-900">Manage</h1>
|
||||
<span className="text-sm text-bakery-600">signed in as {me.name}</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mb-6 rounded-xl bg-red-50 px-4 py-3 text-red-800">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 mb-8">
|
||||
{(['products', 'enquiries'] as Tab[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors ${
|
||||
tab === t
|
||||
? 'bg-bakery-600 text-white border-bakery-600'
|
||||
: 'bg-white border-bakery-300 text-bakery-700 hover:bg-bakery-100'
|
||||
}`}
|
||||
>
|
||||
{t === 'products' ? `Products (${products.length})` : `Enquiries (${enquiries.length})`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'products' ? (
|
||||
<>
|
||||
<Link
|
||||
to="/admin/products/new"
|
||||
className="inline-block mb-6 px-6 py-2.5 bg-bakery-600 text-white rounded-full text-sm tracking-wide hover:bg-bakery-700 transition-colors"
|
||||
>
|
||||
Add a product
|
||||
</Link>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{products.map((p) => (
|
||||
<div key={p.id} className="bg-white rounded-3xl overflow-hidden shadow-xs">
|
||||
{p.imageUrls[0] && (
|
||||
<img src={p.imageUrls[0]} alt="" className="w-full aspect-square object-cover" />
|
||||
)}
|
||||
<div className="p-5">
|
||||
<div className="font-adbhashitha text-lg text-bakery-900">{p.name}</div>
|
||||
<div className="text-xs uppercase tracking-[0.15em] text-bakery-600 mb-4">
|
||||
{p.category} · {p.imageKeys.length} photo{p.imageKeys.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
<div className="flex gap-3 text-sm">
|
||||
<Link
|
||||
to={`/admin/products/${p.id}`}
|
||||
className="underline underline-offset-4 text-bakery-700 hover:text-bakery-900"
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => remove(p)}
|
||||
className="underline underline-offset-4 text-red-700 hover:text-red-900"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{products.length === 0 && <p className="text-bakery-700">Nothing on the menu yet.</p>}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{enquiries.map((e) => (
|
||||
<div key={e.id} className="bg-white rounded-3xl p-6 shadow-xs">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div className="font-medium text-bakery-900">
|
||||
{e.name} <a href={`mailto:${e.email}`} className="font-normal text-bakery-600 underline underline-offset-4">{e.email}</a>
|
||||
</div>
|
||||
<div className="text-sm text-bakery-600">
|
||||
{new Date(e.receivedAt).toLocaleString()}
|
||||
{!e.delivered && (
|
||||
// The enquiry was saved but the relay refused it — nobody got an email.
|
||||
<span className="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800">
|
||||
not emailed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-wrap text-bakery-800">{e.message}</p>
|
||||
</div>
|
||||
))}
|
||||
{enquiries.length === 0 && <p className="text-bakery-700">No enquiries yet.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
createProduct, fetchAdminProducts, updateProduct, uploadPhoto,
|
||||
type AdminProduct,
|
||||
} from '@/lib/api';
|
||||
import { useAuth, signIn } from '@/lib/auth';
|
||||
|
||||
export default function ProductEditorPage() {
|
||||
const { id } = useParams();
|
||||
const editing = id !== undefined;
|
||||
const navigate = useNavigate();
|
||||
const { me, loading } = useAuth();
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [position, setPosition] = useState<number | null>(null);
|
||||
const [images, setImages] = useState<{ key: string; url: string }[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing || !me.admin) return;
|
||||
fetchAdminProducts()
|
||||
.then((all) => {
|
||||
const p = all.find((x: AdminProduct) => String(x.id) === id);
|
||||
if (!p) { setError('That product no longer exists.'); return; }
|
||||
setName(p.name);
|
||||
setCategory(p.category);
|
||||
setPosition(p.position);
|
||||
setImages(p.imageKeys.map((k, i) => ({ key: k, url: p.imageUrls[i] })));
|
||||
})
|
||||
.catch((e) => setError(String(e.message ?? e)));
|
||||
}, [editing, id, me.admin]);
|
||||
|
||||
if (loading) return <p className="container mx-auto px-4 py-20 text-bakery-700">Loading…</p>;
|
||||
if (!me.admin) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-20 text-center">
|
||||
<button onClick={signIn} className="px-8 py-3.5 bg-bakery-600 text-white rounded-full">Sign in</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function onFiles(files: FileList | null) {
|
||||
if (!files?.length) return;
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
// Sequentially, so the order the photos are chosen is the order they appear on the card.
|
||||
for (const file of Array.from(files)) {
|
||||
const t = await uploadPhoto(file);
|
||||
setImages((list) => [...list, { key: t.key, url: t.publicUrl }]);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(String((e as Error).message));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
const form = { name, category, position, imageKeys: images.map((i) => i.key) };
|
||||
if (editing) await updateProduct(Number(id), form);
|
||||
else await createProduct(form);
|
||||
navigate('/admin');
|
||||
} catch (err) {
|
||||
setError(String((err as Error).message));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= images.length) return;
|
||||
setImages((list) => {
|
||||
const next = [...list];
|
||||
const [it] = next.splice(from, 1);
|
||||
next.splice(to, 0, it);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12 max-w-2xl">
|
||||
<h1 className="font-adbhashitha text-4xl text-bakery-900 mb-8">
|
||||
{editing ? 'Edit product' : 'Add a product'}
|
||||
</h1>
|
||||
|
||||
{error && <p role="alert" className="mb-6 rounded-xl bg-red-50 px-4 py-3 text-red-800">{error}</p>}
|
||||
|
||||
<form onSubmit={save} className="bg-white rounded-3xl p-8 shadow-xs">
|
||||
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="name">Name</label>
|
||||
<input
|
||||
id="name" value={name} onChange={(e) => setName(e.target.value)} required
|
||||
className="mb-5 w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500"
|
||||
/>
|
||||
|
||||
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="category">Category</label>
|
||||
<input
|
||||
id="category" value={category} onChange={(e) => setCategory(e.target.value)} required
|
||||
placeholder="Cakes, Cookies, Rolls, Pie, Brownies, Pastries"
|
||||
className="mb-1 w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500"
|
||||
/>
|
||||
<p className="mb-5 text-xs text-bakery-600">
|
||||
A new category appears as its own filter button, after the ones the site already knows.
|
||||
</p>
|
||||
|
||||
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="photos">Photos</label>
|
||||
<input
|
||||
id="photos" type="file" accept="image/*" multiple disabled={busy}
|
||||
onChange={(e) => onFiles(e.target.files)}
|
||||
className="mb-4 block w-full text-sm text-bakery-700 file:mr-4 file:rounded-full file:border-0 file:bg-bakery-600 file:px-5 file:py-2 file:text-white"
|
||||
/>
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="mb-6 grid grid-cols-3 gap-3">
|
||||
{images.map((img, i) => (
|
||||
<div key={img.key} className="relative">
|
||||
<img src={img.url} alt="" className="aspect-square w-full rounded-xl object-cover" />
|
||||
{i === 0 && (
|
||||
<span className="absolute top-1 left-1 rounded-full bg-bakery-900/70 px-2 py-0.5 text-[10px] uppercase tracking-wide text-white">
|
||||
card
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-1 flex justify-between text-xs text-bakery-700">
|
||||
<button type="button" onClick={() => move(i, i - 1)} aria-label="Move earlier">←</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImages((l) => l.filter((_, j) => j !== i))}
|
||||
className="text-red-700"
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
<button type="button" onClick={() => move(i, i + 1)} aria-label="Move later">→</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || images.length === 0}
|
||||
className="px-8 py-3 bg-bakery-600 text-white rounded-full tracking-wide hover:bg-bakery-700 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{busy ? 'Working…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" onClick={() => navigate('/admin')} className="text-bakery-700 underline underline-offset-4">
|
||||
Cancel
|
||||
</button>
|
||||
{images.length === 0 && <span className="text-sm text-bakery-600">Add at least one photo.</span>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -62,8 +62,10 @@
|
||||
<groupId>net.thebennett.platform</groupId>
|
||||
<artifactId>platform-starter-contact</artifactId>
|
||||
</dependency>
|
||||
<!-- Admin needs a login, and uploading a product photo needs the bucket. The public site
|
||||
still reads photos straight from the bucket's public URLs. -->
|
||||
<!-- The site is still a public brochure, but the catalogue is now editable from it: the admin
|
||||
screens sign in against Authentik (OIDC) and upload photos to the bucket. Both are off
|
||||
unless platform.security.mode=OIDC, which is what gates the admin endpoints existing at
|
||||
all — see AdminProductController. -->
|
||||
<dependency>
|
||||
<groupId>net.thebennett.platform</groupId>
|
||||
<artifactId>platform-starter-security</artifactId>
|
||||
@@ -85,8 +87,8 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- springSecurity() for MockMvc: without it the filter chain is absent and every protected
|
||||
path answers 200, which would make a security test prove the opposite of what it says. -->
|
||||
<!-- springSecurity() for MockMvc — without it the filter chain is absent and every protected
|
||||
path answers 200, so a security test would prove the opposite of what it claims. -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
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,175 +0,0 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
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.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import com.itsthevine.web.domain.ContactEnquiry;
|
||||
import com.itsthevine.web.domain.ContactEnquiryRepository;
|
||||
import com.itsthevine.web.domain.Product;
|
||||
import com.itsthevine.web.domain.ProductRepository;
|
||||
|
||||
import net.thebennett.platform.storage.StorageService;
|
||||
|
||||
/**
|
||||
* Everything behind the login: the catalogue, and the enquiries people have sent.
|
||||
*
|
||||
* <p>The whole of {@code /api/admin/**} is gated by {@code platform.security.authenticated-paths}, so
|
||||
* any signed-in Authentik user is an administrator here. That is deliberate for a two-person bakery —
|
||||
* the alternative is a role model nobody would maintain.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
public class AdminController {
|
||||
|
||||
private final ProductRepository products;
|
||||
private final ContactEnquiryRepository enquiries;
|
||||
private final StorageService storage;
|
||||
private final String bucket;
|
||||
private final String publicBaseUrl;
|
||||
|
||||
public AdminController(ProductRepository products, ContactEnquiryRepository enquiries,
|
||||
StorageService storage,
|
||||
@Value("${vine.storage.bucket:itsthevine}") String bucket,
|
||||
@Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String publicBaseUrl) {
|
||||
this.products = products;
|
||||
this.enquiries = enquiries;
|
||||
this.storage = storage;
|
||||
this.bucket = bucket;
|
||||
this.publicBaseUrl = publicBaseUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
// ---- products ----
|
||||
|
||||
/** @param imageKeys bucket keys, in display order; the first is the one the card shows */
|
||||
public record ProductForm(String name, String category, Integer position, List<String> imageKeys) {}
|
||||
|
||||
public record AdminProduct(Long id, String name, String category, int position,
|
||||
List<String> imageKeys, List<String> imageUrls) {}
|
||||
|
||||
@GetMapping("/products")
|
||||
@Transactional(readOnly = true)
|
||||
public List<AdminProduct> list() {
|
||||
return products.findAllByOrderByPositionAsc().stream().map(this::toAdmin).toList();
|
||||
}
|
||||
|
||||
@PostMapping("/products")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@Transactional
|
||||
public AdminProduct create(@RequestBody ProductForm form) {
|
||||
validate(form);
|
||||
// Default to the end of the list so a new product does not silently displace an existing one.
|
||||
int position = form.position() != null ? form.position() : nextPosition();
|
||||
return toAdmin(products.save(new Product(form.name().trim(), form.category().trim(),
|
||||
position, cleanKeys(form.imageKeys()))));
|
||||
}
|
||||
|
||||
@PutMapping("/products/{id}")
|
||||
@Transactional
|
||||
public AdminProduct update(@PathVariable Long id, @RequestBody ProductForm form) {
|
||||
validate(form);
|
||||
Product p = products.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product"));
|
||||
p.update(form.name().trim(), form.category().trim(),
|
||||
form.position() != null ? form.position() : p.getPosition(),
|
||||
cleanKeys(form.imageKeys()));
|
||||
return toAdmin(p);
|
||||
}
|
||||
|
||||
@DeleteMapping("/products/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
@Transactional
|
||||
public void delete(@PathVariable Long id) {
|
||||
if (!products.existsById(id)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no such product");
|
||||
}
|
||||
// The photos stay in the bucket: they are cheap, and an accidental delete is recoverable if
|
||||
// the images survive it.
|
||||
products.deleteById(id);
|
||||
}
|
||||
|
||||
// ---- enquiries ----
|
||||
|
||||
/** @param delivered false means the relay refused it and nobody was notified */
|
||||
public record AdminEnquiry(Long id, String name, String email, String message,
|
||||
boolean delivered, Instant receivedAt) {}
|
||||
|
||||
@GetMapping("/enquiries")
|
||||
@Transactional(readOnly = true)
|
||||
public List<AdminEnquiry> enquiries() {
|
||||
return enquiries.findAllByOrderByCreatedAtDesc().stream()
|
||||
.map(e -> new AdminEnquiry(e.getId(), e.getName(), e.getEmail(), e.getMessage(),
|
||||
e.isDelivered(), e.getCreatedAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ---- photo upload ----
|
||||
|
||||
/**
|
||||
* @param key what to store on the product
|
||||
* @param uploadUrl short-lived; the browser PUTs the file straight to the bucket so the photo
|
||||
* never passes through this app
|
||||
* @param publicUrl where it will be readable from afterwards
|
||||
*/
|
||||
public record UploadTarget(String key, String uploadUrl, String publicUrl) {}
|
||||
|
||||
@PostMapping("/images/presign-upload")
|
||||
public UploadTarget presignUpload(@RequestParam String filename,
|
||||
@RequestParam(defaultValue = "application/octet-stream") String contentType) {
|
||||
// A UUID prefix rather than the bare filename: two people uploading "cake.jpg" must not
|
||||
// overwrite each other, and the bucket is public so keys should not be guessable.
|
||||
String safe = filename.toLowerCase().replaceAll("[^a-z0-9._-]", "-");
|
||||
String key = "images/products/" + UUID.randomUUID() + "-" + safe;
|
||||
return new UploadTarget(key.substring("images/".length()),
|
||||
storage.presignPut(bucket, key, contentType).toString(),
|
||||
publicBaseUrl + "/" + key);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private void validate(ProductForm form) {
|
||||
if (form.name() == null || form.name().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a name");
|
||||
}
|
||||
if (form.category() == null || form.category().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs a category");
|
||||
}
|
||||
if (form.imageKeys() == null || cleanKeys(form.imageKeys()).isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "a product needs at least one photo");
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> cleanKeys(List<String> keys) {
|
||||
return keys == null ? List.of()
|
||||
: keys.stream().filter(k -> k != null && !k.isBlank()).map(String::trim).toList();
|
||||
}
|
||||
|
||||
private int nextPosition() {
|
||||
return products.findAllByOrderByPositionAsc().stream()
|
||||
.mapToInt(Product::getPosition).max().orElse(0) + 1;
|
||||
}
|
||||
|
||||
private AdminProduct toAdmin(Product p) {
|
||||
return new AdminProduct(p.getId(), p.getName(), p.getCategory(), p.getPosition(),
|
||||
p.getImageKeys(), p.getImageKeys().stream().map(this::publicUrl).toList());
|
||||
}
|
||||
|
||||
private String publicUrl(String key) {
|
||||
return publicBaseUrl + "/images/" + key.replaceAll("^/+", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Who, if anyone, is signed in.
|
||||
*
|
||||
* <p>Deliberately PUBLIC: the SPA asks on every page load, and if this required a login the site would
|
||||
* bounce anonymous visitors — every one of them — to Authentik just to render the front page.
|
||||
*/
|
||||
@RestController
|
||||
public class MeController {
|
||||
|
||||
/** @param admin true for any signed-in user; there is one level of access here */
|
||||
public record Me(boolean authenticated, boolean admin, String name) {}
|
||||
|
||||
@GetMapping("/api/me")
|
||||
public Me me() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
boolean signedIn = auth != null && auth.isAuthenticated()
|
||||
&& !"anonymousUser".equals(auth.getPrincipal());
|
||||
if (!signedIn) {
|
||||
return new Me(false, false, null);
|
||||
}
|
||||
String name = auth.getName();
|
||||
if (auth.getPrincipal() instanceof OidcUser user) {
|
||||
name = user.getPreferredUsername() != null ? user.getPreferredUsername() : user.getSubject();
|
||||
}
|
||||
return new Me(true, true, name);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import com.itsthevine.web.domain.Category;
|
||||
import com.itsthevine.web.domain.CategoryRepository;
|
||||
import com.itsthevine.web.domain.Product;
|
||||
import com.itsthevine.web.domain.ProductRepository;
|
||||
|
||||
@@ -28,20 +30,15 @@ public class ProductCatalog {
|
||||
/** The filter shown first — every category at once. Not a stored category. */
|
||||
public static final String ALL = "All";
|
||||
|
||||
/**
|
||||
* Curated display order. The catalogue is sorted for browsing, not alphabetically, and the order
|
||||
* predates the database, so it's stated here. Categories that exist in the data but aren't listed
|
||||
* still show up (appended, alphabetically) rather than silently disappearing from the filter.
|
||||
*/
|
||||
private static final List<String> ORDER =
|
||||
List.of("Cookies", "Cakes", "Rolls", "Pie", "Brownies", "Pastries");
|
||||
|
||||
private final ProductRepository products;
|
||||
private final CategoryRepository categories;
|
||||
private final String assetBaseUrl;
|
||||
|
||||
public ProductCatalog(ProductRepository products,
|
||||
CategoryRepository categories,
|
||||
@Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String assetBaseUrl) {
|
||||
this.products = products;
|
||||
this.categories = categories;
|
||||
// A trailing slash here would produce '//images/...' — harmless on most servers, but it shows
|
||||
// up in every image URL on the page.
|
||||
this.assetBaseUrl = assetBaseUrl.replaceAll("/+$", "");
|
||||
@@ -60,23 +57,41 @@ public class ProductCatalog {
|
||||
return found.stream().map(this::toView).toList();
|
||||
}
|
||||
|
||||
/** The filter buttons, in display order, starting with "All". */
|
||||
/**
|
||||
* The filter buttons, in display order, starting with "All".
|
||||
*
|
||||
* Order comes from the category table. Only categories something is actually filed under are
|
||||
* offered — an empty filter button is a dead end — and a category found on a product but missing
|
||||
* from the table still shows up (appended, alphabetically) rather than silently disappearing.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<String> categories() {
|
||||
Set<String> present = products.findAllByOrderByPositionAsc().stream()
|
||||
.map(Product::getCategory)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
|
||||
List<String> defined = categories.findAllByOrderByPositionAsc().stream()
|
||||
.map(Category::getName)
|
||||
.toList();
|
||||
|
||||
List<String> ordered = new ArrayList<>();
|
||||
ordered.add(ALL);
|
||||
ORDER.stream().filter(present::contains).forEach(ordered::add);
|
||||
defined.stream().filter(present::contains).forEach(ordered::add);
|
||||
present.stream()
|
||||
.filter(c -> !ORDER.contains(c))
|
||||
.filter(c -> !defined.contains(c))
|
||||
.sorted(Comparator.naturalOrder())
|
||||
.forEach(ordered::add);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public so the admin screens render photos through exactly the same URL building as the shop
|
||||
* front — an editor should never arrange something that looks different once it's live.
|
||||
*/
|
||||
public ProductView view(Product product) {
|
||||
return toView(product);
|
||||
}
|
||||
|
||||
private ProductView toView(Product p) {
|
||||
return new ProductView(p.getId(), p.getName(), p.getCategory(),
|
||||
p.getImageKeys().stream().map(this::imageUrl).toList());
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import net.thebennett.platform.storage.StorageService;
|
||||
|
||||
/**
|
||||
* Turns whatever came off a phone into the one shape the bucket holds: a resized webp.
|
||||
*
|
||||
* The photos already in the bucket were re-encoded by hand once (50 MB of originals became 14 MB).
|
||||
* Uploads go through the same treatment so the catalogue doesn't slowly fill with 12 MP JPEGs, and
|
||||
* so nothing arrives carrying the GPS coordinates of the bakery's kitchen — decoding to a
|
||||
* {@link BufferedImage} and re-encoding drops every EXIF tag, because a raster has nowhere to put
|
||||
* them.
|
||||
*
|
||||
* Encoding shells out to {@code cwebp}. No pure-Java webp *writer* exists (TwelveMonkeys and
|
||||
* NightMonkeys both read only), and the libraries that do write bundle glibc natives that will not
|
||||
* load on the Alpine runtime — so the Dockerfile installs Alpine's own musl build of libwebp-tools
|
||||
* and we hand it bytes.
|
||||
*/
|
||||
@Service
|
||||
public class ProductPhotoService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ProductPhotoService.class);
|
||||
|
||||
/** Big enough for a full-bleed card on a retina screen; far smaller than anything a phone shoots. */
|
||||
private static final int MAX_EDGE = 2000;
|
||||
private static final int QUALITY = 82;
|
||||
private static final long ENCODE_TIMEOUT_SECONDS = 30;
|
||||
|
||||
private final StorageService storage;
|
||||
private final String bucket;
|
||||
private final String keyPrefix;
|
||||
|
||||
public ProductPhotoService(StorageService storage,
|
||||
@Value("${site.assets.bucket:itsthevine}") String bucket,
|
||||
@Value("${site.assets.key-prefix:images/}") String keyPrefix) {
|
||||
this.storage = storage;
|
||||
this.bucket = bucket;
|
||||
// ProductCatalog builds public URLs as <base>/images/<stored key>, so the object itself lives
|
||||
// one level deeper than the key we persist. Keeping the prefix here means the database keeps
|
||||
// storing exactly what it stores today.
|
||||
this.keyPrefix = keyPrefix.replaceAll("^/+", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the key to persist on the product — bucket-relative and WITHOUT the {@code images/}
|
||||
* prefix, matching everything already in {@code product_image}
|
||||
*/
|
||||
public String store(byte[] original, String filename, String nameHint) {
|
||||
BufferedImage decoded = decode(original, filename);
|
||||
byte[] webp = encodeWebp(resize(decoded));
|
||||
|
||||
String key = "products/" + slug(nameHint) + "-" + token() + ".webp";
|
||||
storage.put(bucket, keyPrefix + key, webp, "image/webp");
|
||||
log.info("stored product photo {} ({} KB from {} KB)", key, webp.length / 1024, original.length / 1024);
|
||||
return key;
|
||||
}
|
||||
|
||||
private BufferedImage decode(byte[] bytes, String filename) {
|
||||
try {
|
||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
|
||||
if (image == null) {
|
||||
// ImageIO returns null rather than throwing when no reader claims the bytes — HEIC off
|
||||
// an iPhone lands here, as does anything that isn't really an image.
|
||||
throw new IllegalArgumentException(
|
||||
"That file isn't an image we can read (" + filename + "). JPEG or PNG works.");
|
||||
}
|
||||
return image;
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Could not read " + filename + ".", e);
|
||||
}
|
||||
}
|
||||
|
||||
private BufferedImage resize(BufferedImage source) {
|
||||
int width = source.getWidth();
|
||||
int height = source.getHeight();
|
||||
double scale = Math.min(1.0, (double) MAX_EDGE / Math.max(width, height));
|
||||
int targetWidth = Math.max(1, (int) Math.round(width * scale));
|
||||
int targetHeight = Math.max(1, (int) Math.round(height * scale));
|
||||
|
||||
// TYPE_INT_RGB regardless of scale: it flattens any alpha channel onto a known background and
|
||||
// gives cwebp a predictable input. The photos are opaque product shots.
|
||||
BufferedImage target = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D g = target.createGraphics();
|
||||
try {
|
||||
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
||||
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
|
||||
g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
|
||||
} finally {
|
||||
g.dispose();
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Temp files rather than piping through stdin/stdout: cwebp's stream handling varies by build, and
|
||||
* a couple of files in the container's tmpdir is a cheaper bet than debugging that in production.
|
||||
*/
|
||||
private byte[] encodeWebp(BufferedImage image) {
|
||||
Path png = null;
|
||||
Path webp = null;
|
||||
try {
|
||||
png = Files.createTempFile("vine-photo-", ".png");
|
||||
webp = Files.createTempFile("vine-photo-", ".webp");
|
||||
if (!ImageIO.write(image, "png", png.toFile())) {
|
||||
throw new IllegalStateException("No PNG writer available to hand cwebp.");
|
||||
}
|
||||
|
||||
Process process = new ProcessBuilder(
|
||||
"cwebp", "-quiet", "-q", String.valueOf(QUALITY),
|
||||
png.toString(), "-o", webp.toString())
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
|
||||
if (!process.waitFor(ENCODE_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
throw new IllegalStateException("Encoding that photo took too long.");
|
||||
}
|
||||
if (process.exitValue() != 0) {
|
||||
String output = new String(process.getInputStream().readAllBytes()).trim();
|
||||
throw new IllegalStateException("Could not convert that photo. " + output);
|
||||
}
|
||||
return Files.readAllBytes(webp);
|
||||
} catch (IOException e) {
|
||||
// The usual cause is cwebp not being installed — worth saying so plainly.
|
||||
throw new IllegalStateException("Photo conversion is unavailable on this server.", e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Photo conversion was interrupted.", e);
|
||||
} finally {
|
||||
delete(png);
|
||||
delete(webp);
|
||||
}
|
||||
}
|
||||
|
||||
private void delete(Path path) {
|
||||
if (path == null) return;
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn("could not clean up {}", path, e);
|
||||
}
|
||||
}
|
||||
|
||||
private String slug(String value) {
|
||||
String slug = value == null ? "" : value.toLowerCase(Locale.ROOT)
|
||||
.replaceAll("[^a-z0-9]+", "-")
|
||||
.replaceAll("^-|-$", "");
|
||||
return slug.isBlank() ? "photo" : slug;
|
||||
}
|
||||
|
||||
/** Short random suffix so re-uploading the same dish never overwrites the previous photo. */
|
||||
private String token() {
|
||||
byte[] bytes = new byte[4];
|
||||
ThreadLocalRandom.current().nextBytes(bytes);
|
||||
return HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.itsthevine.web.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import net.thebennett.platform.data.BaseEntity;
|
||||
|
||||
/**
|
||||
* A filter button on the products page, and the order it sits in.
|
||||
*
|
||||
* Products still record their category by name, so this table is reference data rather than the
|
||||
* owner of the relationship — it exists to say which categories the bakery offers and in what order
|
||||
* to show them, both of which used to be a constant in the code.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "category")
|
||||
public class Category extends BaseEntity {
|
||||
|
||||
@Column(nullable = false, length = 60, unique = true)
|
||||
private String name;
|
||||
|
||||
/** Display order of the filter buttons, after "All". */
|
||||
@Column(name = "position", nullable = false)
|
||||
private int position;
|
||||
|
||||
protected Category() {
|
||||
// for JPA
|
||||
}
|
||||
|
||||
public Category(String name, int position) {
|
||||
this.name = name;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public void rename(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public void moveTo(int position) {
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
public int getPosition() { return position; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.itsthevine.web.domain;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface CategoryRepository extends JpaRepository<Category, Long> {
|
||||
|
||||
List<Category> findAllByOrderByPositionAsc();
|
||||
|
||||
Optional<Category> findByNameIgnoreCase(String name);
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.OrderColumn;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import org.hibernate.annotations.BatchSize;
|
||||
|
||||
import net.thebennett.platform.data.BaseEntity;
|
||||
|
||||
/** Something the bakery makes, with the photos that show it off. */
|
||||
@@ -34,16 +32,11 @@ public class Product extends BaseEntity {
|
||||
/**
|
||||
* Object keys, not URLs — where the bucket lives is deployment configuration, so the absolute
|
||||
* URL is built at the edge of the app ({@code ProductCatalog}) rather than baked into the data.
|
||||
*
|
||||
* <p>{@code @BatchSize} because the products page loads the whole catalogue at once: without it
|
||||
* Hibernate issues a separate query per product for its photos — forty-odd round trips for a page
|
||||
* that needs two.
|
||||
*/
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "product_image", joinColumns = @JoinColumn(name = "product_id"))
|
||||
@OrderColumn(name = "position")
|
||||
@Column(name = "image_key", nullable = false, length = 300)
|
||||
@BatchSize(size = 64)
|
||||
private List<String> imageKeys = new ArrayList<>();
|
||||
|
||||
protected Product() {
|
||||
@@ -57,15 +50,25 @@ public class Product extends BaseEntity {
|
||||
this.imageKeys = new ArrayList<>(imageKeys);
|
||||
}
|
||||
|
||||
/** Replaces every editable field — the admin form always submits the whole product. */
|
||||
public void update(String name, String category, int position, List<String> imageKeys) {
|
||||
/** Rename and/or refile the item; both are free text the editor typed. */
|
||||
public void describe(String name, String category) {
|
||||
this.name = name;
|
||||
this.category = category;
|
||||
}
|
||||
|
||||
/** Where this sits on the products page. Reordering renumbers the whole catalogue. */
|
||||
public void moveTo(int position) {
|
||||
this.position = position;
|
||||
// Mutate in place rather than reassigning: Hibernate tracks THIS list instance, and handing it
|
||||
// a different one makes it delete and re-insert every row.
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the photo list wholesale. {@code @OrderColumn} makes Hibernate rewrite the tail of the
|
||||
* collection on any insert or removal anyway, so there's nothing to gain from finer-grained
|
||||
* mutators — and one path in means the stored order always matches what the editor arranged.
|
||||
*/
|
||||
public void replacePhotos(List<String> keys) {
|
||||
this.imageKeys.clear();
|
||||
this.imageKeys.addAll(imageKeys);
|
||||
this.imageKeys.addAll(keys);
|
||||
}
|
||||
|
||||
public String getName() { return name; }
|
||||
|
||||
@@ -36,30 +36,44 @@ platform:
|
||||
data:
|
||||
auditing:
|
||||
enabled: true
|
||||
security:
|
||||
# Public site: only the admin API needs a login. An allowlist of public paths would mean
|
||||
# enumerating every static directory, and anything missed 401s — which is exactly how the
|
||||
# confessions site broke its own cover images. mode=OIDC comes from the deploy env so tests
|
||||
# stay on NONE.
|
||||
authenticated-paths:
|
||||
- /api/admin/**
|
||||
storage:
|
||||
endpoint: ${S3_ENDPOINT:https://s3.thebennett.net}
|
||||
access-key: ${S3_ACCESS_KEY:}
|
||||
secret-key: ${S3_SECRET_KEY:}
|
||||
path-style-access: true
|
||||
contact:
|
||||
to: ${CONTACT_TO:}
|
||||
from: ${CONTACT_FROM:}
|
||||
hub-url: ${CONTACT_HUB_URL:}
|
||||
|
||||
vine:
|
||||
security:
|
||||
# Unset means the platform's permit-all chain, which is what a brochure site wants and what the
|
||||
# web contract expects (an unknown /api path must 404, not 401). Set SECURITY_MODE=OIDC in the
|
||||
# deployment to turn on Authentik login — that, and only that, brings the admin endpoints into
|
||||
# existence. Leaving it unset in dev keeps `mvn spring-boot:run` working with no identity provider.
|
||||
mode: ${SECURITY_MODE:NONE}
|
||||
permit-paths:
|
||||
- /api/products
|
||||
- /api/categories
|
||||
- /api/contact
|
||||
- /actuator/health/**
|
||||
authenticated-paths:
|
||||
- /api/admin/**
|
||||
# The admin screen itself, not just its API. The platform sends /api/** a bare 401 (right for
|
||||
# fetch) but bounces everything else to Authentik, so protecting the page means a browser that
|
||||
# opens /admin lands on the login form and comes back signed in — rather than loading an editor
|
||||
# whose every request immediately fails. It also keeps the page out of strangers' hands entirely.
|
||||
- /admin/**
|
||||
storage:
|
||||
bucket: ${VINE_BUCKET:itsthevine}
|
||||
# Only consulted when someone uploads a photo, i.e. only in a deployment that also set OIDC above.
|
||||
# Blank endpoint leaves the storage auto-config switched off, so tests and local runs boot without
|
||||
# MinIO credentials.
|
||||
endpoint: ${STORAGE_ENDPOINT:}
|
||||
access-key: ${STORAGE_ACCESS_KEY:}
|
||||
secret-key: ${STORAGE_SECRET_KEY:}
|
||||
|
||||
# Absolute URLs for og:url. Only matters to link-preview scrapers, which need a full URL.
|
||||
site:
|
||||
base-url: ${SITE_BASE_URL:https://itsthevine.com}
|
||||
assets:
|
||||
# Where uploaded photos land. The key stored on a product is bucket-relative and excludes the
|
||||
# prefix, because ProductCatalog re-adds `/images/` when it builds the public URL.
|
||||
bucket: ${STORAGE_BUCKET:itsthevine}
|
||||
key-prefix: images/
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- The filter buttons were a hard-coded List.of(...) in ProductCatalog. That meant adding a category
|
||||
-- was a deploy, and anything an editor invented appeared last, alphabetically, with no way to move
|
||||
-- it. This makes the order data so the admin screens can arrange it.
|
||||
--
|
||||
-- product.category deliberately stays a varchar holding the name rather than becoming a foreign key:
|
||||
-- every existing row, query and derived repository method keeps working untouched, and six rows of
|
||||
-- reference data don't warrant rewriting the catalogue's shape. Renaming a category updates the
|
||||
-- products alongside it, in one transaction.
|
||||
create table category (
|
||||
id bigserial primary key,
|
||||
name varchar(60) not null unique,
|
||||
position integer not null,
|
||||
created_at timestamptz not null,
|
||||
updated_at timestamptz
|
||||
);
|
||||
|
||||
-- Seeded in the order the page has always shown them, so the site looks identical the moment this
|
||||
-- lands. Categories found on products but missing here still appear on the filter (appended
|
||||
-- alphabetically) rather than silently vanishing.
|
||||
insert into category (name, position, created_at) values
|
||||
('Cookies', 1, now()),
|
||||
('Cakes', 2, now()),
|
||||
('Rolls', 3, now()),
|
||||
('Pie', 4, now()),
|
||||
('Brownies', 5, now()),
|
||||
('Pastries', 6, now());
|
||||
@@ -1,146 +0,0 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
import com.itsthevine.web.domain.ProductRepository;
|
||||
|
||||
/**
|
||||
* The admin catalogue operations. Whether they're reachable without a login is covered separately by
|
||||
* {@link AdminSecurityTest} — this is about what they do once you're in.
|
||||
*/
|
||||
@SpringBootTest(properties = {
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"platform.storage.access-key=test",
|
||||
"platform.storage.secret-key=test",
|
||||
"site.assets.base-url=https://s3.example.test/itsthevine"
|
||||
})
|
||||
@Testcontainers
|
||||
class AdminControllerTest {
|
||||
|
||||
@Container
|
||||
@ServiceConnection
|
||||
static PostgreSQLContainer<?> postgres =
|
||||
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
|
||||
|
||||
@Autowired
|
||||
AdminController admin;
|
||||
|
||||
@Autowired
|
||||
ProductCatalog catalog;
|
||||
|
||||
@Autowired
|
||||
ProductRepository products;
|
||||
|
||||
private static AdminController.ProductForm form(String name, String category, List<String> keys) {
|
||||
return new AdminController.ProductForm(name, category, null, keys);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsAProductAndItAppearsOnThePublicSite() {
|
||||
int before = catalog.list(null).size();
|
||||
|
||||
var created = admin.create(form("Test Loaf", "Rolls", List.of("products/test-loaf.webp")));
|
||||
|
||||
assertThat(created.id()).isNotNull();
|
||||
assertThat(catalog.list(null)).hasSize(before + 1);
|
||||
assertThat(catalog.list("Rolls"))
|
||||
.extracting(ProductCatalog.ProductView::name)
|
||||
.contains("Test Loaf");
|
||||
|
||||
admin.delete(created.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aNewProductGoesToTheEndRatherThanDisplacingOne() {
|
||||
// Position defaults matter: reusing an existing one would reorder the curated catalogue.
|
||||
int maxBefore = admin.list().stream().mapToInt(AdminController.AdminProduct::position).max().orElse(0);
|
||||
|
||||
var created = admin.create(form("末 Loaf", "Rolls", List.of("products/x.webp")));
|
||||
|
||||
assertThat(created.position()).isGreaterThan(maxBefore);
|
||||
admin.delete(created.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void editingReplacesTheFieldsAndKeepsTheOrderOfPhotos() {
|
||||
var created = admin.create(form("Before", "Cakes", List.of("products/a.webp", "products/b.webp")));
|
||||
|
||||
var updated = admin.update(created.id(),
|
||||
new AdminController.ProductForm("After", "Pie", 3,
|
||||
List.of("products/b.webp", "products/a.webp", "products/c.webp")));
|
||||
|
||||
assertThat(updated.name()).isEqualTo("After");
|
||||
assertThat(updated.category()).isEqualTo("Pie");
|
||||
assertThat(updated.position()).isEqualTo(3);
|
||||
assertThat(updated.imageKeys())
|
||||
.containsExactly("products/b.webp", "products/a.webp", "products/c.webp");
|
||||
|
||||
admin.delete(created.id());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aProductWithoutAPhotoIsRejected() {
|
||||
// The card is a photo with a caption; without one it renders as an empty square.
|
||||
assertThatThrownBy(() -> admin.create(form("No photo", "Cakes", List.of())))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("at least one photo");
|
||||
assertThatThrownBy(() -> admin.create(form("No photo", "Cakes", List.of(" "))))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aProductWithoutANameOrCategoryIsRejected() {
|
||||
assertThatThrownBy(() -> admin.create(form(" ", "Cakes", List.of("products/a.webp"))))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("name");
|
||||
assertThatThrownBy(() -> admin.create(form("Thing", " ", List.of("products/a.webp"))))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("category");
|
||||
}
|
||||
|
||||
@Test
|
||||
void editingSomethingThatIsGoneIs404NotACrash() {
|
||||
assertThatThrownBy(() -> admin.update(9_999_999L, form("x", "Cakes", List.of("products/a.webp"))))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("404");
|
||||
assertThatThrownBy(() -> admin.delete(9_999_999L))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("404");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletingRemovesItFromThePublicCatalogue() {
|
||||
var created = admin.create(form("Temporary", "Brownies", List.of("products/t.webp")));
|
||||
assertThat(catalog.list("Brownies")).extracting(ProductCatalog.ProductView::name).contains("Temporary");
|
||||
|
||||
admin.delete(created.id());
|
||||
|
||||
assertThat(catalog.list("Brownies")).extracting(ProductCatalog.ProductView::name)
|
||||
.doesNotContain("Temporary");
|
||||
assertThat(products.findById(created.id())).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminListsCarryBothKeysAndUrlsSoTheEditorCanShowThumbnails() {
|
||||
var created = admin.create(form("Thumb", "Cookies", List.of("products/thumb.webp")));
|
||||
|
||||
var found = admin.list().stream().filter(p -> p.id().equals(created.id())).findFirst().orElseThrow();
|
||||
assertThat(found.imageKeys()).containsExactly("products/thumb.webp");
|
||||
assertThat(found.imageUrls())
|
||||
.containsExactly("https://s3.example.test/itsthevine/images/products/thumb.webp");
|
||||
|
||||
admin.delete(created.id());
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package com.itsthevine.web;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -12,8 +11,8 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
@@ -22,26 +21,26 @@ import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
/**
|
||||
* What an anonymous visitor can and cannot reach.
|
||||
* What an anonymous visitor can and cannot reach, with security on as production runs it.
|
||||
*
|
||||
* <p>This is the test that matters most on this branch: the admin API can create, edit and delete the
|
||||
* menu, and the whole site is otherwise public. Running with {@code platform.security.mode=OIDC}, as
|
||||
* production does — the default of NONE would leave everything open and prove nothing.
|
||||
* <p>The admin can create, edit, reorder and delete the whole menu, and the shop is otherwise public —
|
||||
* so the boundary between them is the thing most worth a test. Runs with {@code SECURITY_MODE=OIDC}
|
||||
* because the default of NONE leaves everything open and would prove nothing.
|
||||
*/
|
||||
@SpringBootTest(properties = {
|
||||
"platform.security.mode=OIDC",
|
||||
// Endpoints stated outright rather than an issuer-uri: an issuer-uri makes Spring fetch the
|
||||
// discovery document at startup, which needs the network and a real identity provider.
|
||||
"SECURITY_MODE=OIDC",
|
||||
// Endpoints stated outright rather than an issuer-uri, which would make Spring fetch the
|
||||
// discovery document at startup — that needs the network and a real identity provider.
|
||||
"spring.security.oauth2.client.provider.authentik.authorization-uri=https://sso.example.test/authorize",
|
||||
"spring.security.oauth2.client.provider.authentik.token-uri=https://sso.example.test/token",
|
||||
"spring.security.oauth2.client.provider.authentik.jwk-set-uri=https://sso.example.test/jwks",
|
||||
"spring.security.oauth2.client.provider.authentik.user-info-uri=https://sso.example.test/userinfo",
|
||||
"spring.security.oauth2.client.provider.authentik.user-name-attribute=preferred_username",
|
||||
"spring.security.oauth2.client.registration.authentik.client-id=test",
|
||||
"spring.security.oauth2.client.registration.authentik.client-secret=test",
|
||||
"spring.security.oauth2.client.registration.authentik.scope=openid,profile,email",
|
||||
"spring.security.oauth2.client.registration.authentik.authorization-grant-type=authorization_code",
|
||||
"spring.security.oauth2.client.registration.authentik.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}",
|
||||
"spring.security.oauth2.client.registration.authentik.client-id=test",
|
||||
"spring.security.oauth2.client.registration.authentik.client-secret=test",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"platform.storage.access-key=test",
|
||||
@@ -62,52 +61,51 @@ class AdminSecurityTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// .apply(springSecurity()) is not optional here: webAppContextSetup alone leaves the security
|
||||
// filter chain out, so every protected path returns 200 and the test proves nothing.
|
||||
// .apply(springSecurity()) is not optional: webAppContextSetup alone leaves the filter chain
|
||||
// out, so every protected path returns 200 and the test would assert nothing.
|
||||
mvc = MockMvcBuilders.webAppContextSetup(context)
|
||||
.apply(SecurityMockMvcConfigurers.springSecurity())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyAdminEndpointIsClosedToAnonymousVisitors() throws Exception {
|
||||
// 401 rather than a redirect: the platform's security starter answers /api/** with a status so
|
||||
// the SPA can handle it, instead of bouncing an XHR to the identity provider.
|
||||
void everyAdminApiIsClosedToAnonymousVisitors() throws Exception {
|
||||
// csrf() on the writes, so these assert AUTHORIZATION (401), not a missing token.
|
||||
mvc.perform(get("/api/admin/products")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(get("/api/admin/enquiries")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(post("/api/admin/products").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"x\",\"category\":\"Cakes\",\"imageKeys\":[\"a\"]}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
mvc.perform(post("/api/admin/images/presign-upload?filename=x.jpg").with(csrf()))
|
||||
mvc.perform(get("/api/admin/categories")).andExpect(status().isUnauthorized());
|
||||
mvc.perform(post("/api/admin/products").with(csrf())).andExpect(status().isUnauthorized());
|
||||
mvc.perform(post("/api/admin/categories").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"x\"}"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theAdminPageRedirectsABrowserToLogin() throws Exception {
|
||||
// Protecting /admin server-side is what makes sign-in work: a browser opening it is bounced to
|
||||
// Authentik and comes back signed in. The redirect only fires for a request that prefers HTML —
|
||||
// the platform answers */* (a fetch/XHR) with a bare 401 so the SPA can handle it — so this
|
||||
// must send a browser's Accept header to see the 302. (Verified against a running container.)
|
||||
mvc.perform(get("/admin").header("Accept", "text/html,application/xhtml+xml"))
|
||||
.andExpect(status().is3xxRedirection());
|
||||
}
|
||||
|
||||
@Test
|
||||
void theShopStaysPublic() throws Exception {
|
||||
// The whole point of authenticated-paths: locking the admin API must not lock the menu.
|
||||
// Locking the admin must not lock the menu.
|
||||
mvc.perform(get("/api/products")).andExpect(status().isOk());
|
||||
mvc.perform(get("/api/categories")).andExpect(status().isOk());
|
||||
mvc.perform(post("/api/contact").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"Ada\",\"email\":\"nope\",\"message\":\"hi\"}"))
|
||||
.andExpect(status().isBadRequest()); // reached the controller, rejected on content
|
||||
}
|
||||
|
||||
@Test
|
||||
void theContactFormNeedsItsCsrfToken() throws Exception {
|
||||
// Turning on the security starter turns on CSRF, which applies to the PUBLIC contact form too.
|
||||
// Without the token the form silently 403s — the SPA reads the XSRF-TOKEN cookie and sends
|
||||
// X-XSRF-TOKEN for exactly this reason.
|
||||
void theContactFormStillNeedsItsCsrfToken() throws Exception {
|
||||
// Enabling the security starter enables CSRF for the PUBLIC contact form too. Without the token
|
||||
// it 403s; the SPA reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN.
|
||||
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"Ada\",\"email\":\"[email protected]\",\"message\":\"hi\"}"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void meIsPublicAndSaysNobodyIsSignedIn() throws Exception {
|
||||
// If this required a login, every anonymous visitor would be bounced to Authentik on page load.
|
||||
mvc.perform(get("/api/me"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.authenticated").value(false))
|
||||
.andExpect(jsonPath("$.admin").value(false));
|
||||
// With the token it reaches the controller (400 on the deliberately bad email below).
|
||||
mvc.perform(post("/api/contact").with(csrf()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"name\":\"Ada\",\"email\":\"nope\",\"message\":\"hi\"}"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user