From fa8686276d129256ef60116e2a0f88b3b826d85e Mon Sep 17 00:00:00 2001 From: Michilis Date: Wed, 5 Aug 2026 22:05:33 +0000 Subject: [PATCH] Share gallery layout with loading skeletons and add download progress. Keep the public gallery hero/masonry geometry stable across route and client loading, and show busy state while photos download. Co-authored-by: Cursor --- .../(public)/events/[id]/gallery/loading.tsx | 6 + .../(public)/photos/[slug]/GalleryClient.tsx | 160 +++++++++--------- .../app/(public)/photos/[slug]/loading.tsx | 7 + frontend/src/components/Lightbox.tsx | 60 ++++++- .../src/components/gallery/GalleryLayout.tsx | 46 +++++ .../components/gallery/GallerySkeleton.tsx | 59 +++++++ frontend/src/components/gallery/PhotoTile.tsx | 100 +++++++++++ .../src/components/gallery/useDownloads.tsx | 148 ++++++++++++++++ frontend/src/components/ui/Skeleton.tsx | 14 +- frontend/src/components/ui/Spinner.tsx | 18 ++ 10 files changed, 524 insertions(+), 94 deletions(-) create mode 100644 frontend/src/app/(public)/events/[id]/gallery/loading.tsx create mode 100644 frontend/src/app/(public)/photos/[slug]/loading.tsx create mode 100644 frontend/src/components/gallery/GalleryLayout.tsx create mode 100644 frontend/src/components/gallery/GallerySkeleton.tsx create mode 100644 frontend/src/components/gallery/PhotoTile.tsx create mode 100644 frontend/src/components/gallery/useDownloads.tsx create mode 100644 frontend/src/components/ui/Spinner.tsx diff --git a/frontend/src/app/(public)/events/[id]/gallery/loading.tsx b/frontend/src/app/(public)/events/[id]/gallery/loading.tsx new file mode 100644 index 0000000..facfe07 --- /dev/null +++ b/frontend/src/app/(public)/events/[id]/gallery/loading.tsx @@ -0,0 +1,6 @@ +import GallerySkeleton from '@/components/gallery/GallerySkeleton'; + +// Shown while the server component fetches the event gallery. +export default function Loading() { + return ; +} diff --git a/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx index 101025e..ea39a09 100644 --- a/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx +++ b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx @@ -7,11 +7,17 @@ import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { photosApi, PhotoGallery, Photo } from '@/lib/api'; import Button from '@/components/ui/Button'; -import { ImageGridSkeleton } from '@/components/ui/Skeleton'; +import GallerySkeleton from '@/components/gallery/GallerySkeleton'; +import PhotoTile from '@/components/gallery/PhotoTile'; +import { + GalleryContainer, + GalleryHeroFrame, + MasonryGrid, +} from '@/components/gallery/GalleryLayout'; +import { useDownloads } from '@/components/gallery/useDownloads'; import Lightbox from '@/components/Lightbox'; import LoginModal from '@/components/LoginModal'; import { - ArrowDownTrayIcon, CalendarIcon, CameraIcon, LinkIcon, @@ -42,6 +48,17 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien const [denied, setDenied] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(null); const [loginOpen, setLoginOpen] = useState(false); + const downloads = useDownloads(es); + const downloadLabels = { + download: es ? 'Descargar' : 'Download', + downloading: es ? 'Descargando…' : 'Downloading…', + }; + const downloadPhoto = (photo: Photo) => + downloads.start({ + id: photo.id, + url: photo.urls.original, + filename: photo.originalFilename, + }); // Server-rendered public galleries need no client fetch. Everything else // (link/ticket/private) is fetched here with the share token and/or the @@ -78,14 +95,10 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien }; }, [slug, eventSlug, shareToken, initial, authLoading, user?.id]); + // Mirrors the hero + masonry layout below, so the real page drops straight + // into the placeholder's geometry instead of replacing it. if (loading || (authLoading && !initial)) { - return ( -
-
- -
-
- ); + return ; } // Gate pages for restricted galleries. After a successful login in the @@ -254,47 +267,48 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien return (
- {/* Hero */} -
- {heroUrl && ( - <> - {/* eslint-disable-next-line @next/next/no-img-element */} - -
- + {/* Hero — same frame the skeleton renders (GalleryLayout). */} + + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + ) : null + } + > +

+ {title} +

+ {description && ( +

{description}

)} -
-

- {title} -

- {description && ( -

{description}

+
+ + + {readyPhotos.length} {es ? 'fotos' : 'photos'} + + {gallery.event && ( + + + {eventTitle} + {eventDate && · {eventDate}} + )} -
- - - {readyPhotos.length} {es ? 'fotos' : 'photos'} - - {gallery.event && ( - - - {eventTitle} - {eventDate && · {eventDate}} - - )} -
-
+ {/* Masonry grid */} -
+ {readyPhotos.length === 0 ? (
@@ -303,46 +317,19 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien

) : ( -
+ {readyPhotos.map((photo, i) => ( -
setLightboxIndex(i)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setLightboxIndex(i); - } - }} - className="group relative mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow" - style={ - photo.width && photo.height - ? { aspectRatio: `${photo.width} / ${photo.height}` } - : undefined - } - > - {/* eslint-disable-next-line @next/next/no-img-element */} - - + photo={photo} + eager={i < 8} + onOpen={() => setLightboxIndex(i)} + onDownload={() => downloadPhoto(photo)} + downloading={downloads.isPending(photo.id)} + labels={downloadLabels} + /> ))} -
+
)} {lightboxIndex !== null && ( @@ -351,9 +338,16 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien index={lightboxIndex} onClose={() => setLightboxIndex(null)} onNavigate={setLightboxIndex} + onDownload={(it) => + downloads.start({ id: it.id, url: it.downloadUrl, filename: it.filename }) + } + downloadingId={ + lightboxItems.find((it) => downloads.isPending(it.id))?.id ?? null + } + downloadLabels={downloadLabels} /> )} -
+
{/* Call to action: send attendees to their dashboard, everyone else to the next event. Auth state comes from the same useAuth() the gate diff --git a/frontend/src/app/(public)/photos/[slug]/loading.tsx b/frontend/src/app/(public)/photos/[slug]/loading.tsx new file mode 100644 index 0000000..c5ac403 --- /dev/null +++ b/frontend/src/app/(public)/photos/[slug]/loading.tsx @@ -0,0 +1,7 @@ +import GallerySkeleton from '@/components/gallery/GallerySkeleton'; + +// Shown while the server component fetches the gallery, so the first paint is +// already the gallery's layout rather than an empty page. +export default function Loading() { + return ; +} diff --git a/frontend/src/components/Lightbox.tsx b/frontend/src/components/Lightbox.tsx index d77fe56..d9aa42f 100644 --- a/frontend/src/components/Lightbox.tsx +++ b/frontend/src/components/Lightbox.tsx @@ -7,6 +7,7 @@ import { ChevronRightIcon, ArrowDownTrayIcon, } from '@heroicons/react/24/outline'; +import Spinner from '@/components/ui/Spinner'; export interface LightboxItem { id: string; @@ -24,17 +25,35 @@ interface LightboxProps { onNavigate: (index: number) => void; /** Extra per-item action buttons rendered in the top bar (admin use). */ renderActions?: (item: LightboxItem, index: number) => React.ReactNode; + /** + * Handles the download in JS instead of navigating, so the button can show + * progress. Without it the button stays a plain . + */ + onDownload?: (item: LightboxItem) => void; + /** Id of the item currently downloading (pairs with onDownload). */ + downloadingId?: string | null; + downloadLabels?: { download: string; downloading: string }; } /** * Full-screen photo lightbox with keyboard and swipe navigation, in the * style of the admin gallery preview modal (fixed inset-0 bg-black/90). */ -export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) { +export default function Lightbox({ + items, + index, + onClose, + onNavigate, + renderActions, + onDownload, + downloadingId, + downloadLabels, +}: LightboxProps) { const touchStart = useRef<{ x: number; y: number } | null>(null); const activeThumbRef = useRef(null); const item = items[index]; const hasMultiple = items.length > 1; + const downloading = !!downloadingId && item?.id === downloadingId; const prev = useCallback(() => { onNavigate(index > 0 ? index - 1 : items.length - 1); @@ -98,14 +117,37 @@ export default function Lightbox({ items, index, onClose, onNavigate, renderActi className="absolute top-4 left-4 z-10 flex items-center gap-4" onClick={(e) => e.stopPropagation()} > - - - + {onDownload ? ( + + ) : ( + + + + )} {renderActions?.(item, index)}
diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx new file mode 100644 index 0000000..37871f9 --- /dev/null +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -0,0 +1,46 @@ +// Layout shell shared by the public gallery page (GalleryClient) and its +// loading placeholder (GallerySkeleton). Column count, gaps, radii and +// container padding are defined once here, so the skeleton's geometry can +// never drift from the grid it stands in for. + +export function GalleryHeroFrame({ + children, + backdrop, +}: { + children: React.ReactNode; + /** Cover image + scrim, absolutely positioned behind the text. */ + backdrop?: React.ReactNode; +}) { + return ( +
+ {backdrop} +
{children}
+
+ ); +} + +export function GalleryContainer({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function MasonryGrid({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// Per-tile geometry. Deliberately carries no background so callers can pick +// one (photo tiles: bg-gray-100, skeleton tiles: the skeleton surface) without +// two `bg-*` utilities fighting over CSS order. +export const masonryTileClass = 'mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl'; + +/** + * Reserves the tile's aspect ratio up front so the image can load into a box + * that is already the right size — late arrivals never reflow the columns. + * Returns undefined when the photo has no stored dimensions. + */ +export function aspectStyle(width?: number, height?: number): React.CSSProperties | undefined { + return width && height ? { aspectRatio: `${width} / ${height}` } : undefined; +} diff --git a/frontend/src/components/gallery/GallerySkeleton.tsx b/frontend/src/components/gallery/GallerySkeleton.tsx new file mode 100644 index 0000000..60eccc4 --- /dev/null +++ b/frontend/src/components/gallery/GallerySkeleton.tsx @@ -0,0 +1,59 @@ +import clsx from 'clsx'; +import { Skeleton, SkeletonGroup } from '@/components/ui/Skeleton'; +import { GalleryContainer, GalleryHeroFrame, MasonryGrid, masonryTileClass } from './GalleryLayout'; + +// Loading placeholder for the public gallery page. It renders through the same +// hero frame, container and masonry grid as the real page (see GalleryLayout), +// so replacing it with photos changes only the pixels inside the tiles. + +// Portrait/landscape/square mix standing in for a real event set. Fixed order +// on purpose — a random shuffle would differ between the server and client +// render and blow up hydration. +const FALLBACK_RATIOS = [3 / 4, 4 / 3, 1, 2 / 3, 3 / 2, 4 / 5, 1, 3 / 4, 16 / 9, 4 / 5, 3 / 4, 4 / 3]; + +interface GallerySkeletonProps { + /** Number of tiles to draw; ignored when `ratios` is given. */ + count?: number; + /** + * Real width/height ratios when the caller already knows them (e.g. a + * cached gallery payload). Produces a grid with exactly the right geometry + * instead of the guessed mix above. + */ + ratios?: number[]; +} + +export default function GallerySkeleton({ count = 12, ratios }: GallerySkeletonProps) { + const tiles = + ratios && ratios.length > 0 + ? ratios + : Array.from({ length: count }, (_, i) => FALLBACK_RATIOS[i % FALLBACK_RATIOS.length]); + + return ( + + + {/* Matches the h1 (text-3xl / md:text-5xl) and the pill row below it. */} + +
+ + +
+
+ + + + {tiles.map((ratio, i) => ( +