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:
Michilis
2026-07-25 17:32:23 +00:00
co-authored by Cursor
parent 4772b85f3d
commit c9a600b6d6
61 changed files with 6912 additions and 7 deletions
@@ -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>
);
}
+30
View File
@@ -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} />;
}