Archived
Admin for the menu and enquiries, plus gallery fixes
Admin - /api/admin: products CRUD, the enquiry inbox, and presigned photo upload straight to the bucket so images never pass through the app. Gated by platform.security.authenticated-paths = /api/admin/**, so any signed-in Authentik user is staff — the alternative is a role model a two-person bakery would never maintain. - /api/me is deliberately PUBLIC. The SPA asks on every page load, and requiring a login would bounce every anonymous visitor to Authentik just to read the menu. - /admin screens: product list with edit and remove, an editor with drag-free photo reordering and upload, and an enquiry inbox that flags anything the relay refused. Gallery - swipe on touch devices, which the react-awesome-slider it replaced had and this did not, plus arrow keys and position dots — with swipe there is otherwise nothing to say a card holds more than one photo. Vertical drags are ignored so page scrolling still works. - @BatchSize on the photo collection: the products page loaded the whole catalogue and Hibernate issued a query per product for its images, forty-odd round trips for a page that needs two. Three things the tests caught, none of which are obvious: - Adding the storage starter broke every existing test. It activates on a default endpoint, so an S3 client is built even in tests and dies on blank keys. - MockMvc's webAppContextSetup leaves the security filter chain OUT, so the first version of the security test passed 200s and proved the opposite of what it claimed. It needs .apply(springSecurity()). - Turning on the security starter turns on CSRF — for the PUBLIC contact form too, which then 403s. The SPA now reads the XSRF-TOKEN cookie and sends X-XSRF-TOKEN, and there is a test asserting the form is rejected without it. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
This commit is contained in:
@@ -7,6 +7,9 @@ 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';
|
||||
|
||||
/**
|
||||
* Client-side navigation keeps the previous scroll position, which lands you halfway down a page you
|
||||
@@ -23,6 +26,7 @@ const ScrollToTop = () => {
|
||||
};
|
||||
|
||||
const App = () => (
|
||||
<AuthProvider>
|
||||
<div className="min-h-screen bg-bakery-50 flex flex-col">
|
||||
<ScrollToTop />
|
||||
<Header />
|
||||
@@ -32,11 +36,15 @@ const App = () => (
|
||||
<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>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
interface ProductGalleryProps {
|
||||
images: string[];
|
||||
@@ -11,16 +11,51 @@ interface ProductGalleryProps {
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** 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);
|
||||
const many = images.length > 1;
|
||||
const touchStart = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length);
|
||||
|
||||
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;
|
||||
if (!start || !many) return;
|
||||
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);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative aspect-square bg-bakery-100">
|
||||
<div
|
||||
className="relative aspect-square bg-bakery-100"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onKeyDown={many ? (e) => {
|
||||
if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
|
||||
if (e.key === 'ArrowRight') { e.preventDefault(); step(1); }
|
||||
} : undefined}
|
||||
tabIndex={many ? 0 : undefined}
|
||||
role={many ? 'group' : undefined}
|
||||
aria-roledescription={many ? 'carousel' : undefined}
|
||||
aria-label={many ? `${alt} — ${images.length} photos` : undefined}
|
||||
>
|
||||
{images.map((src, i) => (
|
||||
<img
|
||||
key={src}
|
||||
@@ -54,6 +89,18 @@ const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
|
||||
>
|
||||
<span aria-hidden="true">{'>'}</span>
|
||||
</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">
|
||||
{images.map((src, i) => (
|
||||
<span
|
||||
key={src}
|
||||
className={`h-1.5 w-1.5 rounded-full transition ${
|
||||
i === index ? 'bg-white' : 'bg-white/40'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,16 @@ 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) } : {};
|
||||
}
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
|
||||
@@ -21,3 +31,83 @@ export const fetchProducts = (category?: string) =>
|
||||
get<Product[]>(category && category !== 'All' ? `/api/products?category=${encodeURIComponent(category)}` : '/api/products');
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
async function send<T>(path: string, method: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...csrfHeaders() },
|
||||
body: 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);
|
||||
}
|
||||
return res.status === 204 ? (undefined as T) : (res.json() as Promise<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');
|
||||
|
||||
export interface UploadTarget { key: string; uploadUrl: string; publicUrl: string }
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
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.
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<{ me: Me; loading: boolean }>({ me: ANON, loading: true });
|
||||
|
||||
useEffect(() => {
|
||||
fetchMe()
|
||||
.then((me) => setState({ me, loading: false }))
|
||||
.catch(() => setState({ me: ANON, loading: false }));
|
||||
}, []);
|
||||
|
||||
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 = () => { window.location.href = '/oauth2/authorization/authentik'; };
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { submitContact } from '@/lib/api';
|
||||
|
||||
type Status = 'idle' | 'sending' | 'sent' | 'error';
|
||||
|
||||
@@ -23,13 +24,7 @@ const ContactPage = () => {
|
||||
setStatus('sending');
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Could not send the message.');
|
||||
await submitContact(formData);
|
||||
setStatus('sent');
|
||||
setFormData({ name: '', email: '', message: '' });
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user