Archived
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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user