Reconcile: your admin wins, keeping main's non-admin work

You built a self-service catalogue admin on feature/admin-and-ui-wins while I built a
competing one that had already merged and deployed. Both forked from c8cc8fe. Per your
call, your implementation is the one that stays.

Kept from main (files your branch didn't touch, so no conflict):
- the CI test gate (tests now run and block the image)
- motion 12.42.2
- the platform contract test

Took from your branch:
- split AdminProductController / AdminCategoryController + ProductPhotoService (server-side
  webp via cwebp)
- a real category table (Category, V3__categories.sql) behind the product filters
- pages/Admin.tsx, with server-side /admin protection that redirects a browser to Authentik
  and returns it to /admin afterward — cleaner than my client-side gate, and it avoids the
  post-login-to-home issue my version had

Deleted my competing admin (AdminController, MeController, pages/admin/*, auth.tsx, and my
admin tests).

Grafted onto your gallery: swipe + arrow keys, which the deployed version had and yours
didn't. Added an AdminSecurityTest for your endpoints (admin closed, shop public, contact
CSRF) — the admin was otherwise untested, and CI now gates on tests.

Verified against a running container: /admin redirects a browser to Authentik (a bare 401
only for */* fetches, which is correct). 25 tests green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
This commit is contained in:
2026-07-23 13:10:46 -05:00
co-authored by Claude Opus 4.8
parent 021dd0db8d
commit 651ff1e30c
26 changed files with 1790 additions and 1037 deletions
+32 -16
View File
@@ -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">
<Routes>
<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 />} />
</Routes>
</main>
<Footer />
</div>
</AuthProvider>
</Route>
<Route element={<AdminLayout />}>
<Route path="/admin" element={<AdminPage />} />
</Route>
</Routes>
</>
);
export default App;
+103 -91
View File
@@ -1,105 +1,117 @@
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 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();
const navItems = [
{ label: 'Our Products', href: '/products' },
{ label: 'Our Story', href: '/history' },
{ label: 'Contact', href: '/contact' },
];
// 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">
{/* Logo */}
<Logo className="text-bakery-700" />
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-8">
{navItems.map((item) => (
<Link
key={item.href}
to={item.href}
className="text-sm uppercase tracking-[0.15em] text-bakery-700 hover:text-bakery-900 transition"
<>
<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">
{/* Logo */}
<Logo className="text-bakery-700" />
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-8">
{navItems.map((item) => (
<Link
key={item.href}
to={item.href}
className="text-sm uppercase tracking-[0.15em] text-bakery-700 hover:text-bakery-900 transition"
>
{item.label}
</Link>
))}
</nav>
{/* 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((open) => !open)}
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={isMobileMenuOpen}
>
<svg
className="h-6 w-6 text-bakery-700"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
{item.label}
</Link>
))}
</nav>
{/* Mobile menu button */}
<button
className="md:hidden p-2 shrink-0"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Toggle menu"
aria-expanded={isMobileMenuOpen}
>
<svg
className="h-6 w-6 text-bakery-700"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
{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. */}
<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"
>
<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">
{navItems.map((item) => (
<Link
key={item.href}
to={item.href}
className="text-bakery-800 hover:text-bakery-600 transition py-4 text-lg"
onClick={() => setIsMobileMenuOpen(false)}
>
{item.label}
</Link>
))}
</nav>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</header>
{/* 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={{ 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"
>
<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 border-b border-bakery-100"
onClick={() => setIsMobileMenuOpen(false)}
>
{item.label}
</Link>
))}
</nav>
</motion.div>
)}
</AnimatePresence>
</>
);
};
+53 -20
View File
@@ -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'
}`}
/>
))}
+87 -74
View File
@@ -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.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);
if (res.status === 401 || res.status === 403) {
throw new Error('Your sign-in has expired — refresh the page to sign in again.');
}
return res.status === 204 ? (undefined as T) : (res.json() as Promise<T>);
if (!res.ok) {
const problem = await res.json().catch(() => null);
throw new Error(problem?.detail || problem?.error || 'That did not save. Please try again.');
}
return (res.status === 204 ? undefined : await res.json()) as T;
}
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');
-53
View File
@@ -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';
};
+716
View File
@@ -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;
+11 -2
View File
@@ -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) {
+29 -37
View File
@@ -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,39 +59,33 @@ 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.map((product) => (
<motion.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"
>
{/* Image Container */}
<div className="relative w-full overflow-hidden">
<ProductGallery images={product.images} alt={product.name} />
</div>
{/* 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) => (
<div
key={product.id}
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">
<ProductGallery images={product.images} alt={product.name} />
</div>
{/* Content */}
<div className="p-6 text-center">
<h3 className="font-adbhashitha text-xl text-bakery-900 mb-2 tracking-wide">
{product.name}
</h3>
<span className="text-xs uppercase tracking-[0.15em] text-bakery-600">
{product.category}
</span>
</div>
</motion.div>
))}
</AnimatePresence>
</motion.div>
{/* Content */}
<div className="p-6 text-center">
<h3 className="font-adbhashitha text-xl text-bakery-900 mb-2 tracking-wide">
{product.name}
</h3>
<span className="text-xs uppercase tracking-[0.15em] text-bakery-600">
{product.category}
</span>
</div>
</div>
))}
</div>
</div>
</div>
);
-148
View File
@@ -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>
);
}