Add photo galleries API and frontend for public and admin viewing.
Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
import type { PhotoGallery, Photo } from '@/lib/api';
|
||||
import GalleryClient from '@/app/(public)/photos/[slug]/GalleryClient';
|
||||
|
||||
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||
|
||||
interface PageProps {
|
||||
params: { id: string }; // event slug, same param name as the parent route
|
||||
}
|
||||
|
||||
// Public event galleries render server-side; link/ticket galleries return
|
||||
// null here and are fetched client-side with the viewer's token.
|
||||
async function getEventGallery(
|
||||
eventSlug: string
|
||||
): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${photoApiUrl}/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery`,
|
||||
{ next: { revalidate: 300 } }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const data = await getEventGallery(params.id);
|
||||
if (!data) {
|
||||
return { title: 'Event Photos', robots: { index: false } };
|
||||
}
|
||||
return {
|
||||
title: `${data.gallery.title} – Photos`,
|
||||
description:
|
||||
data.gallery.description ||
|
||||
`Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function EventGalleryPage({ params }: PageProps) {
|
||||
const initial = await getEventGallery(params.id);
|
||||
return (
|
||||
// Suspense boundary required by useSearchParams() in the client child.
|
||||
<Suspense>
|
||||
<GalleryClient eventSlug={params.id} initial={initial} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import type { PhotoGallery } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { CameraIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export default function PhotosIndexClient({ galleries }: { galleries: PhotoGallery[] }) {
|
||||
const { locale } = useLanguage();
|
||||
const es = locale === 'es';
|
||||
|
||||
const formatDate = (iso?: string) => {
|
||||
if (!iso) return null;
|
||||
return new Date(iso).toLocaleDateString(es ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<h1 className="section-title text-center mb-2">
|
||||
{es ? 'Galerías de Fotos' : 'Photo Galleries'}
|
||||
</h1>
|
||||
<p className="text-center text-gray-600 mb-10">
|
||||
{es
|
||||
? 'Recuerdos de nuestros intercambios de idiomas en Asunción'
|
||||
: 'Memories from our language exchanges in Asunción'}
|
||||
</p>
|
||||
|
||||
{galleries.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 ? 'Aún no hay galerías publicadas.' : 'No galleries published yet.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{galleries.map((g) => (
|
||||
// Event-linked galleries live under the event's URL.
|
||||
<Link key={g.id} href={g.event ? `/events/${g.event.slug}/gallery` : `/photos/${g.slug}`}>
|
||||
<Card variant="elevated" className="overflow-hidden hover:shadow-card-hover transition-shadow h-full">
|
||||
<div className="aspect-video bg-gray-100 flex items-center justify-center">
|
||||
{g.coverUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={g.coverUrl} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<CameraIcon className="w-12 h-12 text-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h2 className="font-heading font-semibold text-lg text-primary-dark">
|
||||
{es && g.titleEs ? g.titleEs : g.title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{g.photoCount} {es ? 'fotos' : 'photos'}
|
||||
{g.event?.startDatetime && <> · {formatDate(g.event.startDatetime)}</>}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams, useRouter, usePathname } 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 { ImageGridSkeleton } from '@/components/ui/Skeleton';
|
||||
import Lightbox from '@/components/Lightbox';
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
CalendarIcon,
|
||||
CameraIcon,
|
||||
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' | '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 pathname = usePathname();
|
||||
const router = useRouter();
|
||||
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);
|
||||
|
||||
// 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 so the Bearer token is available
|
||||
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;
|
||||
const msg = err.message || '';
|
||||
if (msg.includes('Authentication required')) setDenied('login');
|
||||
else if (msg.includes('attendees')) setDenied('ticket');
|
||||
else setDenied('notfound');
|
||||
})
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [slug, eventSlug, shareToken, initial, authLoading, user?.id]);
|
||||
|
||||
if (loading || (authLoading && !initial)) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page">
|
||||
<ImageGridSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (denied === 'login') {
|
||||
const redirect = encodeURIComponent(`${pathname}${shareToken ? `?token=${shareToken}` : ''}`);
|
||||
return (
|
||||
<GateMessage
|
||||
icon={LockClosedIcon}
|
||||
title={es ? 'Inicia sesión para ver esta galería' : 'Log in to view this gallery'}
|
||||
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={() => router.push(`/login?redirect=${redirect}`)}>
|
||||
{es ? 'Iniciar sesión' : 'Log in'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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.'
|
||||
: 'This gallery is only available to people who attended the event with a confirmed ticket.'
|
||||
}
|
||||
action={
|
||||
eventSlug || gallery?.event ? (
|
||||
<Link href={`/events/${eventSlug || gallery?.event?.slug}`}>
|
||||
<Button variant="outline">{es ? 'Ver el evento' : 'View the event'}</Button>
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
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 */}
|
||||
<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" />
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Masonry grid */}
|
||||
<div className="container-page px-2 sm:px-4 py-4 md:py-8">
|
||||
{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>
|
||||
) : (
|
||||
<div className="columns-2 sm:columns-3 lg:columns-4 gap-2 md:gap-3 [column-fill:_balance]">
|
||||
{readyPhotos.map((photo, i) => (
|
||||
<div
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<Lightbox
|
||||
items={lightboxItems}
|
||||
index={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onNavigate={setLightboxIndex}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
import type { PhotoGallery, Photo } from '@/lib/api';
|
||||
import GalleryClient from './GalleryClient';
|
||||
|
||||
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||
|
||||
interface PageProps {
|
||||
params: { slug: string };
|
||||
}
|
||||
|
||||
// Public galleries render server-side (SEO + fast first paint); link and
|
||||
// ticket galleries return null here and are fetched client-side with the
|
||||
// viewer's token / share token.
|
||||
async function getPublicGallery(
|
||||
slug: string
|
||||
): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${photoApiUrl}/api/photos/public/galleries/${encodeURIComponent(slug)}`,
|
||||
{ next: { revalidate: 300 } }
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const data = await getPublicGallery(params.slug);
|
||||
if (!data) {
|
||||
return { title: 'Photo Gallery', robots: { index: false } };
|
||||
}
|
||||
return {
|
||||
title: `${data.gallery.title} – Photos`,
|
||||
description:
|
||||
data.gallery.description ||
|
||||
`Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function GalleryPage({ params }: PageProps) {
|
||||
const initial = await getPublicGallery(params.slug);
|
||||
return (
|
||||
// Suspense boundary required by useSearchParams() in the client child.
|
||||
<Suspense>
|
||||
<GalleryClient slug={params.slug} initial={initial} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from 'next';
|
||||
import type { PhotoGallery } from '@/lib/api';
|
||||
import PhotosIndexClient from './PhotosIndexClient';
|
||||
|
||||
// Server-side calls go straight to the photo-api service; the browser uses
|
||||
// the same-origin /api/photos path via the Next rewrite / nginx.
|
||||
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Event Photo Galleries',
|
||||
description: 'Photos from past Spanglish language exchange events in Asunción.',
|
||||
};
|
||||
|
||||
async function getGalleries(): Promise<PhotoGallery[]> {
|
||||
try {
|
||||
const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, {
|
||||
next: { revalidate: 300 },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data.galleries || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function PhotosPage() {
|
||||
const galleries = await getGalleries();
|
||||
return <PhotosIndexClient galleries={galleries} />;
|
||||
}
|
||||
Reference in New Issue
Block a user