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.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user