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>
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import Link from 'next/link';
|
||
import { useSearchParams } from 'next/navigation';
|
||
import { useLanguage } from '@/context/LanguageContext';
|
||
import { useAuth } from '@/context/AuthContext';
|
||
import { photosApi, PhotoGallery, Photo } from '@/lib/api';
|
||
import Button from '@/components/ui/Button';
|
||
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 {
|
||
CalendarIcon,
|
||
CameraIcon,
|
||
LinkIcon,
|
||
LockClosedIcon,
|
||
TicketIcon,
|
||
} from '@heroicons/react/24/outline';
|
||
|
||
interface GalleryClientProps {
|
||
/** Fetch by gallery slug (standalone galleries at /photos/[slug]) … */
|
||
slug?: string;
|
||
/** … or by event slug (event galleries at /events/[slug]/gallery). */
|
||
eventSlug?: string;
|
||
initial: { gallery: PhotoGallery; photos: Photo[] } | null;
|
||
}
|
||
|
||
type DeniedState = 'login' | 'ticket' | 'private' | 'link' | 'notfound' | null;
|
||
|
||
export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) {
|
||
const { locale } = useLanguage();
|
||
const es = locale === 'es';
|
||
const { user, isLoading: authLoading } = useAuth();
|
||
const searchParams = useSearchParams();
|
||
const shareToken = searchParams.get('token') || undefined;
|
||
|
||
const [gallery, setGallery] = useState<PhotoGallery | null>(initial?.gallery ?? null);
|
||
const [photos, setPhotos] = useState<Photo[]>(initial?.photos ?? []);
|
||
const [loading, setLoading] = useState(!initial);
|
||
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
|
||
// viewer's own auth token attached.
|
||
useEffect(() => {
|
||
if (initial) return;
|
||
if (authLoading) return; // wait until the session state has resolved
|
||
let cancelled = false;
|
||
setLoading(true);
|
||
const fetcher = eventSlug
|
||
? photosApi.getEventGallery(eventSlug, shareToken)
|
||
: photosApi.getPublicGallery(slug || '', shareToken);
|
||
fetcher
|
||
.then((data) => {
|
||
if (cancelled) return;
|
||
setGallery(data.gallery);
|
||
setPhotos(data.photos);
|
||
setDenied(null);
|
||
})
|
||
.catch((err: Error) => {
|
||
if (cancelled) return;
|
||
// The photo-api returns a distinct message per visibility mode so
|
||
// each gets its own gate page (see accessDenial in access.go).
|
||
const msg = err.message || '';
|
||
if (msg.includes('Authentication required')) setDenied('login');
|
||
else if (msg.includes('attendees')) setDenied('ticket');
|
||
else if (msg.includes('private')) setDenied('private');
|
||
else if (msg.includes('share link')) setDenied('link');
|
||
else setDenied('notfound');
|
||
})
|
||
.finally(() => !cancelled && setLoading(false));
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [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 <GallerySkeleton count={gallery?.photoCount || undefined} />;
|
||
}
|
||
|
||
// Gate pages for restricted galleries. After a successful login in the
|
||
// pop-up, AuthContext's user changes, which re-runs the fetch effect —
|
||
// the gate resolves by itself when access is granted.
|
||
const loginModal = (
|
||
<LoginModal
|
||
open={loginOpen}
|
||
onClose={() => setLoginOpen(false)}
|
||
message={
|
||
es
|
||
? 'Inicia sesión para ver esta galería.'
|
||
: 'Log in to view this gallery.'
|
||
}
|
||
/>
|
||
);
|
||
|
||
if (denied === 'login') {
|
||
return (
|
||
<>
|
||
<GateMessage
|
||
icon={TicketIcon}
|
||
title={es ? 'Solo para asistentes' : 'Attendees only'}
|
||
body={
|
||
es
|
||
? 'Esta galería es para asistentes del evento. Inicia sesión con la cuenta que usaste para reservar.'
|
||
: 'This gallery is for event attendees. Log in with the account you used to book.'
|
||
}
|
||
action={
|
||
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
|
||
}
|
||
/>
|
||
{loginModal}
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (denied === 'ticket') {
|
||
return (
|
||
<>
|
||
<GateMessage
|
||
icon={TicketIcon}
|
||
title={es ? 'Solo para asistentes' : 'Attendees only'}
|
||
body={
|
||
es
|
||
? 'Esta galería es para quienes asistieron al evento con una entrada confirmada. ¿Reservaste con otra cuenta?'
|
||
: 'This gallery is only available to people who attended the event with a confirmed ticket. Booked with a different account?'
|
||
}
|
||
action={
|
||
<div className="flex flex-wrap justify-center gap-3">
|
||
{(eventSlug || gallery?.event) && (
|
||
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
|
||
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
|
||
</Link>
|
||
)}
|
||
<Button onClick={() => setLoginOpen(true)}>
|
||
{es ? 'Cambiar de cuenta' : 'Switch account'}
|
||
</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
{loginModal}
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (denied === 'private') {
|
||
return (
|
||
<>
|
||
<GateMessage
|
||
icon={LockClosedIcon}
|
||
title={es ? 'Esta galería es privada' : 'This gallery is private'}
|
||
body={
|
||
es
|
||
? 'Solo los organizadores pueden verla. Si eres parte del equipo, inicia sesión.'
|
||
: 'Only the organizers can see it. If that’s you, log in.'
|
||
}
|
||
action={
|
||
<div className="flex flex-wrap justify-center gap-3">
|
||
<Link href="/photos">
|
||
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
|
||
</Link>
|
||
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
{loginModal}
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (denied === 'link') {
|
||
return (
|
||
<>
|
||
<GateMessage
|
||
icon={LinkIcon}
|
||
title={es ? 'Esta galería necesita su enlace' : 'This gallery needs its share link'}
|
||
body={
|
||
es
|
||
? 'Solo se puede abrir con el enlace que compartieron los organizadores. Pídeles el enlace completo, o inicia sesión si eres parte del equipo.'
|
||
: 'It can only be opened with the link the organizers shared. Ask them for the full link, or log in if you’re part of the team.'
|
||
}
|
||
action={
|
||
<div className="flex flex-wrap justify-center gap-3">
|
||
<Link href="/photos">
|
||
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
|
||
</Link>
|
||
<Button onClick={() => setLoginOpen(true)}>{es ? 'Iniciar sesión' : 'Log in'}</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
{loginModal}
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (denied === 'notfound' || !gallery) {
|
||
return (
|
||
<GateMessage
|
||
icon={CameraIcon}
|
||
title={es ? 'Galería no encontrada' : 'Gallery not found'}
|
||
body={
|
||
es
|
||
? 'Este enlace no existe o ya no está disponible.'
|
||
: 'This link does not exist or is no longer available.'
|
||
}
|
||
action={
|
||
<Link href="/photos">
|
||
<Button variant="outline">{es ? 'Ver galerías públicas' : 'Browse public galleries'}</Button>
|
||
</Link>
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb);
|
||
const lightboxItems = readyPhotos.map((p) => ({
|
||
id: p.id,
|
||
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;
|
||
const description = es && gallery.descriptionEs ? gallery.descriptionEs : gallery.description;
|
||
const eventTitle = gallery.event
|
||
? es && gallery.event.titleEs
|
||
? gallery.event.titleEs
|
||
: gallery.event.title
|
||
: null;
|
||
const eventDate = gallery.event?.startDatetime
|
||
? new Date(gallery.event.startDatetime).toLocaleDateString(es ? 'es-ES' : 'en-US', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
timeZone: 'America/Asuncion',
|
||
})
|
||
: null;
|
||
|
||
// Hero backdrop: the cover photo's preview when available, else the
|
||
// first photo. Falls back to a navy gradient with no image.
|
||
const coverPhoto =
|
||
readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null;
|
||
const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null;
|
||
|
||
return (
|
||
<div>
|
||
{/* 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="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>
|
||
</GalleryHeroFrame>
|
||
|
||
{/* Masonry grid */}
|
||
<GalleryContainer>
|
||
{readyPhotos.length === 0 ? (
|
||
<div className="text-center py-16">
|
||
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||
<p className="text-gray-600">
|
||
{es ? 'Las fotos estarán disponibles pronto.' : 'Photos will be available soon.'}
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<MasonryGrid>
|
||
{readyPhotos.map((photo, i) => (
|
||
<PhotoTile
|
||
key={photo.id}
|
||
photo={photo}
|
||
eager={i < 8}
|
||
onOpen={() => setLightboxIndex(i)}
|
||
onDownload={() => downloadPhoto(photo)}
|
||
downloading={downloads.isPending(photo.id)}
|
||
labels={downloadLabels}
|
||
/>
|
||
))}
|
||
</MasonryGrid>
|
||
)}
|
||
|
||
{lightboxIndex !== null && (
|
||
<Lightbox
|
||
items={lightboxItems}
|
||
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}
|
||
/>
|
||
)}
|
||
</GalleryContainer>
|
||
|
||
{/* Call to action: send attendees to their dashboard, everyone else to
|
||
the next event. Auth state comes from the same useAuth() the gate
|
||
pages use. */}
|
||
<div className="bg-brand-navy">
|
||
<div className="container-page px-4 py-12 md:py-16 text-center">
|
||
<h2 className="font-heading font-bold text-2xl md:text-3xl text-white">
|
||
{user
|
||
? es
|
||
? '¿Listo para lo que sigue?'
|
||
: 'Ready for what’s next?'
|
||
: es
|
||
? '¿Te gustó lo que viste?'
|
||
: 'Liked what you saw?'}
|
||
</h2>
|
||
<p className="mt-2 max-w-xl mx-auto text-white/80 text-sm md:text-base">
|
||
{user
|
||
? es
|
||
? 'Revisa tus entradas y próximos eventos en tu panel.'
|
||
: 'Check your tickets and upcoming events from your dashboard.'
|
||
: es
|
||
? 'Únete a nuestro próximo evento y sé parte de las próximas fotos.'
|
||
: 'Join our next event and be part of the next set of photos.'}
|
||
</p>
|
||
<div className="mt-6">
|
||
<Link href={user ? '/dashboard' : '/next'}>
|
||
<Button size="lg">
|
||
{user
|
||
? es
|
||
? 'Ir a mi panel'
|
||
: 'Go to dashboard'
|
||
: es
|
||
? 'Únete al próximo evento'
|
||
: 'Join the next event'}
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function GateMessage({
|
||
icon: Icon,
|
||
title,
|
||
body,
|
||
action,
|
||
}: {
|
||
icon: typeof CameraIcon;
|
||
title: string;
|
||
body: string;
|
||
action?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="section-padding">
|
||
<div className="container-page max-w-lg mx-auto text-center py-16">
|
||
<Icon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||
<h1 className="text-2xl font-bold text-primary-dark mb-2">{title}</h1>
|
||
<p className="text-gray-600 mb-6">{body}</p>
|
||
{action}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|