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 <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-08-05 22:05:33 +00:00
co-authored by Cursor
parent 498d7d8a7d
commit fa8686276d
10 changed files with 524 additions and 94 deletions
@@ -0,0 +1,6 @@
import GallerySkeleton from '@/components/gallery/GallerySkeleton';
// Shown while the server component fetches the event gallery.
export default function Loading() {
return <GallerySkeleton />;
}
@@ -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<DeniedState>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(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 (
<div className="section-padding">
<div className="container-page">
<ImageGridSkeleton />
</div>
</div>
);
return <GallerySkeleton count={gallery?.photoCount || undefined} />;
}
// Gate pages for restricted galleries. After a successful login in the
@@ -254,47 +267,48 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
return (
<div>
{/* Hero */}
<div className="relative bg-brand-navy overflow-hidden">
{heroUrl && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={heroUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-60"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
</>
{/* Hero — same frame the skeleton renders (GalleryLayout). */}
<GalleryHeroFrame
backdrop={
heroUrl ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={heroUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover opacity-60"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-black/20" />
</>
) : null
}
>
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
{title}
</h1>
{description && (
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
)}
<div className="relative container-page px-4 pt-20 pb-8 md:pt-32 md:pb-12">
<h1 className="font-heading font-bold text-3xl md:text-5xl text-white drop-shadow-sm">
{title}
</h1>
{description && (
<p className="mt-2 max-w-2xl text-white/90 text-sm md:text-base">{description}</p>
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
<CameraIcon className="w-4 h-4" />
{readyPhotos.length} {es ? 'fotos' : 'photos'}
</span>
{gallery.event && (
<Link
href={`/events/${gallery.event.slug}`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
>
<CalendarIcon className="w-4 h-4" />
{eventTitle}
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
</Link>
)}
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 backdrop-blur px-3 py-1 text-white">
<CameraIcon className="w-4 h-4" />
{readyPhotos.length} {es ? 'fotos' : 'photos'}
</span>
{gallery.event && (
<Link
href={`/events/${gallery.event.slug}`}
className="inline-flex items-center gap-1.5 rounded-full bg-primary-yellow px-3 py-1 text-primary-dark font-medium hover:brightness-105"
>
<CalendarIcon className="w-4 h-4" />
{eventTitle}
{eventDate && <span className="hidden sm:inline font-normal">· {eventDate}</span>}
</Link>
)}
</div>
</div>
</div>
</GalleryHeroFrame>
{/* Masonry grid */}
<div className="container-page px-2 sm:px-4 py-4 md:py-8">
<GalleryContainer>
{readyPhotos.length === 0 ? (
<div className="text-center py-16">
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
@@ -303,46 +317,19 @@ export default function GalleryClient({ slug, eventSlug, initial }: GalleryClien
</p>
</div>
) : (
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
<MasonryGrid>
{readyPhotos.map((photo, i) => (
<div
<PhotoTile
key={photo.id}
role="button"
tabIndex={0}
onClick={() => 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 */}
<img
src={photo.urls.thumb}
alt=""
loading={i < 8 ? 'eager' : 'lazy'}
className="w-full h-auto group-hover:scale-[1.03] transition-transform duration-300"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
<a
href={photo.urls.original}
download={photo.originalFilename || true}
onClick={(e) => e.stopPropagation()}
className="absolute bottom-2 right-2 hidden md:flex p-2 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80"
aria-label={es ? 'Descargar' : 'Download'}
>
<ArrowDownTrayIcon className="w-4 h-4" />
</a>
</div>
photo={photo}
eager={i < 8}
onOpen={() => setLightboxIndex(i)}
onDownload={() => downloadPhoto(photo)}
downloading={downloads.isPending(photo.id)}
labels={downloadLabels}
/>
))}
</div>
</MasonryGrid>
)}
{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}
/>
)}
</div>
</GalleryContainer>
{/* Call to action: send attendees to their dashboard, everyone else to
the next event. Auth state comes from the same useAuth() the gate
@@ -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 <GallerySkeleton />;
}