From c4094630d97e4dc5769af75f1b9c8c6e6cb9c934 Mon Sep 17 00:00:00 2001 From: Michilis Date: Sun, 26 Jul 2026 05:53:37 +0000 Subject: [PATCH] Add a thumbnail filmstrip to the photo lightbox. - Render a horizontally scrollable strip of thumbnails below the image that highlights and auto-centres the active photo and lets you jump straight to any shot. - Pass thumbUrl through from the public and admin galleries, falling back to previewUrl when no thumbnail exists. - Restructure the overlay into a flex column (image area + strip) and require a clearly horizontal gesture before treating a touch as a prev/next swipe, so vertical scrolls no longer trigger navigation. Co-Authored-By: Claude Fable 5 --- .../(public)/photos/[slug]/GalleryClient.tsx | 1 + frontend/src/app/admin/photos/[id]/page.tsx | 1 + frontend/src/components/Lightbox.tsx | 143 ++++++++++++------ 3 files changed, 101 insertions(+), 44 deletions(-) diff --git a/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx index c75aa2c..29afb66 100644 --- a/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx +++ b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx @@ -149,6 +149,7 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien previewUrl: p.urls.preview || p.urls.original, downloadUrl: p.urls.original, filename: p.originalFilename, + thumbUrl: p.urls.thumb, })); const title = es && gallery.titleEs ? gallery.titleEs : gallery.title; diff --git a/frontend/src/app/admin/photos/[id]/page.tsx b/frontend/src/app/admin/photos/[id]/page.tsx index 6b6808c..3f1dd3d 100644 --- a/frontend/src/app/admin/photos/[id]/page.tsx +++ b/frontend/src/app/admin/photos/[id]/page.tsx @@ -266,6 +266,7 @@ export default function AdminGalleryDetailPage() { previewUrl: p.urls.preview || p.urls.original, downloadUrl: p.urls.original, filename: p.originalFilename, + thumbUrl: p.urls.thumb, })); const openLightboxFor = (photoId: string) => { const idx = readyPhotos.findIndex((p) => p.id === photoId); diff --git a/frontend/src/components/Lightbox.tsx b/frontend/src/components/Lightbox.tsx index e9c5e91..d77fe56 100644 --- a/frontend/src/components/Lightbox.tsx +++ b/frontend/src/components/Lightbox.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useCallback, useState } from 'react'; +import { useEffect, useCallback, useRef, useState } from 'react'; import { XMarkIcon, ChevronLeftIcon, @@ -13,6 +13,8 @@ export interface LightboxItem { previewUrl: string; downloadUrl: string; filename?: string; + /** Small thumbnail for the filmstrip; falls back to previewUrl. */ + thumbUrl?: string; } interface LightboxProps { @@ -29,8 +31,10 @@ interface LightboxProps { * style of the admin gallery preview modal (fixed inset-0 bg-black/90). */ export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) { - const [touchStartX, setTouchStartX] = useState(null); + const touchStart = useRef<{ x: number; y: number } | null>(null); + const activeThumbRef = useRef(null); const item = items[index]; + const hasMultiple = items.length > 1; const prev = useCallback(() => { onNavigate(index > 0 ? index - 1 : items.length - 1); @@ -53,19 +57,33 @@ export default function Lightbox({ items, index, onClose, onNavigate, renderActi }; }, [onClose, prev, next]); + // Keep the active thumbnail centred in the filmstrip as you navigate. + useEffect(() => { + activeThumbRef.current?.scrollIntoView({ + behavior: 'smooth', + inline: 'center', + block: 'nearest', + }); + }, [index]); + if (!item) return null; return (
setTouchStartX(e.touches[0].clientX)} + onTouchStart={(e) => { + touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }; + }} onTouchEnd={(e) => { - if (touchStartX === null) return; - const dx = e.changedTouches[0].clientX - touchStartX; - if (dx > 60) prev(); - if (dx < -60) next(); - setTouchStartX(null); + if (touchStart.current === null) return; + const dx = e.changedTouches[0].clientX - touchStart.current.x; + const dy = e.changedTouches[0].clientY - touchStart.current.y; + touchStart.current = null; + // Only treat as a swipe when it's clearly horizontal. + if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy)) return; + if (dx > 0) prev(); + else next(); }} > - - - )} + {/* Main image area */} +
+ {hasMultiple && ( + <> + + + + )} - {/* eslint-disable-next-line @next/next/no-img-element */} - e.stopPropagation()} + draggable={false} + /> +
+ + {/* Counter + thumbnail filmstrip you can skip through. */} +
e.stopPropagation()} - draggable={false} - /> - -
- {index + 1} / {items.length} + onTouchStart={(e) => e.stopPropagation()} + onTouchEnd={(e) => e.stopPropagation()} + > +
+ {index + 1} / {items.length} +
+ {hasMultiple && ( +
+ {items.map((it, i) => ( + + ))} +
+ )}
{/* Preload neighbours so prev/next feels instant. */}
- {items.length > 1 && ( + {hasMultiple && ( <> {/* eslint-disable-next-line @next/next/no-img-element */}