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
+5
View File
@@ -7,6 +7,11 @@ NEXT_PUBLIC_SITE_URL=https://spanglishcommunity.com
# API URL (leave empty for same-origin proxy)
NEXT_PUBLIC_API_URL=
# Photo gallery service (photo-api) origin, used by the /api/photos rewrite
# and server-side rendering of /photos pages.
# Dev default: http://localhost:3003 — production: http://127.0.0.1:3020
PHOTO_API_URL=
# Google OAuth (optional - leave empty to hide Google Sign-In button)
# Get your Client ID from: https://console.cloud.google.com/apis/credentials
# 1. Create a new OAuth 2.0 Client ID (Web application)
+11
View File
@@ -4,6 +4,10 @@
// being hardcoded to localhost.
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
// The standalone photo gallery service (photo-api/). Must be rewritten
// before the generic /api rule below — Next rewrites are order-sensitive.
const PHOTO_API_URL = process.env.PHOTO_API_URL || 'http://localhost:3003';
// Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN).
const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '')
.split(',')
@@ -20,6 +24,9 @@ const securityHeaders = [
];
const nextConfig = {
// Overridable so a production build can run while `next dev` holds .next
// (e.g. NEXT_DIST_DIR=.next-build npm run build).
distDir: process.env.NEXT_DIST_DIR || '.next',
images: {
// Restrict remote image sources to a known allowlist instead of allowing any
// https host (which let the Next image optimizer be used as an open proxy).
@@ -39,6 +46,10 @@ const nextConfig = {
},
async rewrites() {
return [
{
source: '/api/photos/:path*',
destination: `${PHOTO_API_URL}/api/photos/:path*`,
},
{
source: '/api/:path*',
destination: `${BACKEND_URL}/api/:path*`,
@@ -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} />;
}
+2
View File
@@ -17,6 +17,7 @@ import {
EnvelopeIcon,
InboxIcon,
PhotoIcon,
CameraIcon,
Cog6ToothIcon,
ArrowLeftOnRectangleIcon,
Bars3Icon,
@@ -54,6 +55,7 @@ export default function AdminLayout({
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] },
{ name: t('admin.nav.photos'), href: '/admin/photos', icon: CameraIcon, allowedRoles: ['admin', 'organizer'] },
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] },
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
@@ -0,0 +1,32 @@
'use client';
import { useLanguage } from '@/context/LanguageContext';
import type { GalleryVisibility } from '@/lib/api';
import {
GlobeAltIcon,
LockClosedIcon,
LinkIcon,
TicketIcon,
} from '@heroicons/react/24/outline';
const visibilityMeta: Record<
GalleryVisibility,
{ icon: typeof GlobeAltIcon; className: string; en: string; es: string }
> = {
public: { icon: GlobeAltIcon, className: 'bg-green-100 text-green-700', en: 'Public', es: 'Pública' },
private: { icon: LockClosedIcon, className: 'bg-gray-200 text-gray-700', en: 'Private', es: 'Privada' },
link: { icon: LinkIcon, className: 'bg-blue-100 text-blue-700', en: 'Link only', es: 'Solo enlace' },
ticket: { icon: TicketIcon, className: 'bg-yellow-100 text-yellow-800', en: 'Ticket holders', es: 'Con entrada' },
};
export default function VisibilityBadge({ visibility }: { visibility: GalleryVisibility }) {
const { locale } = useLanguage();
const meta = visibilityMeta[visibility] || visibilityMeta.private;
const Icon = meta.icon;
return (
<span className={`inline-flex items-center gap-1 text-xs px-2 py-1 rounded ${meta.className}`}>
<Icon className="w-3.5 h-3.5" />
{locale === 'es' ? meta.es : meta.en}
</span>
);
}
+617
View File
@@ -0,0 +1,617 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { photosApi, eventsApi, PhotoGallery, Photo, Event, GalleryVisibility } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton';
import Lightbox from '@/components/Lightbox';
import VisibilityBadge from '../VisibilityBadge';
import {
ArrowLeftIcon,
ArrowUpTrayIcon,
ArrowPathIcon,
CheckIcon,
CheckCircleIcon,
ChevronDownIcon,
ChevronUpIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
LinkIcon,
PhotoIcon,
StarIcon,
TrashIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
import toast from 'react-hot-toast';
interface UploadItem {
key: string;
name: string;
sizeBytes: number;
progress: number; // 0..1 while uploading
status: 'queued' | 'uploading' | 'processing' | 'error';
error?: string;
photoId?: string;
}
function formatBytes(n: number) {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
if (n >= 1 << 10) return `${Math.round(n / (1 << 10))} KB`;
return `${n} B`;
}
export default function AdminGalleryDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const { locale } = useLanguage();
const es = locale === 'es';
const [gallery, setGallery] = useState<PhotoGallery | null>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
const [dragId, setDragId] = useState<string | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Uploader panel state (Google Drive style: one row per file).
const [uploads, setUploads] = useState<UploadItem[]>([]);
const [panelCollapsed, setPanelCollapsed] = useState(false);
const uploadQueue = useRef<{ key: string; file: File }[]>([]);
const pumpRunning = useRef(false);
const load = useCallback(async () => {
try {
const [detail, ev] = await Promise.all([photosApi.getGallery(id), eventsApi.getAll()]);
setGallery(detail.gallery);
setPhotos(detail.photos);
setEvents(ev.events);
} catch {
toast.error(es ? 'No se pudo cargar la galería' : 'Failed to load gallery');
router.push('/admin/photos');
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
load();
}, [load]);
// Poll while any photo is still processing so thumbnails (and the
// uploader panel rows) update as the worker finishes them.
const processingCount = photos.filter((p) => p.status === 'queued' || p.status === 'processing').length;
useEffect(() => {
if (processingCount === 0) return;
const timer = setInterval(async () => {
try {
const detail = await photosApi.getGallery(id);
setGallery(detail.gallery);
setPhotos(detail.photos);
} catch {
/* transient; next tick retries */
}
}, 3000);
return () => clearInterval(timer);
}, [processingCount, id]);
const patchUpload = (key: string, patch: Partial<UploadItem>) => {
setUploads((prev) => prev.map((u) => (u.key === key ? { ...u, ...patch } : u)));
};
// Sequential upload pump: one file at a time, byte progress per file.
const pump = useCallback(async () => {
if (pumpRunning.current) return;
pumpRunning.current = true;
while (uploadQueue.current.length > 0) {
const { key, file } = uploadQueue.current.shift()!;
patchUpload(key, { status: 'uploading', progress: 0 });
try {
const { photos: added } = await photosApi.uploadPhotoWithProgress(id, file, (fraction) =>
patchUpload(key, { progress: fraction })
);
setPhotos((prev) => [...prev, ...added]);
patchUpload(key, { status: 'processing', progress: 1, photoId: added[0]?.id });
} catch (err) {
patchUpload(key, {
status: 'error',
error: err instanceof Error ? err.message : 'Upload failed',
});
}
}
pumpRunning.current = false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const handleUpload = (files: FileList | File[]) => {
const list = Array.from(files);
if (list.length === 0) return;
const items: UploadItem[] = list.map((file) => ({
key: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: file.name,
sizeBytes: file.size,
progress: 0,
status: 'queued',
}));
uploadQueue.current.push(...items.map((item, i) => ({ key: item.key, file: list[i] })));
setUploads((prev) => [...prev, ...items]);
setPanelCollapsed(false);
pump();
if (fileInputRef.current) fileInputRef.current.value = '';
};
// A row is "done" once its photo finished processing; the panel derives
// this from the photos list instead of tracking it separately.
const displayStatus = (u: UploadItem): { state: string; error?: string } => {
if (u.status === 'processing' && u.photoId) {
const photo = photos.find((p) => p.id === u.photoId);
if (photo?.status === 'ready') return { state: 'done' };
if (photo?.status === 'failed') return { state: 'error', error: photo.lastError || 'Processing failed' };
}
return { state: u.status, error: u.error };
};
const activeUploads = uploads.filter((u) => {
const s = displayStatus(u).state;
return s === 'queued' || s === 'uploading' || s === 'processing';
}).length;
const saveField = async (patch: Parameters<typeof photosApi.updateGallery>[1], okMsg?: string) => {
try {
const { gallery: updated } = await photosApi.updateGallery(id, patch);
setGallery(updated);
if (okMsg) toast.success(okMsg);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Update failed');
}
};
const copyShareLink = async () => {
if (!gallery?.shareUrl) return;
try {
await navigator.clipboard.writeText(gallery.shareUrl);
setCopied(true);
toast.success(es ? 'Enlace copiado' : 'Share link copied');
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error(es ? 'No se pudo copiar' : 'Failed to copy');
}
};
const rotateToken = async () => {
if (!confirm(es ? '¿Invalidar el enlace actual y generar uno nuevo?' : 'Invalidate the current link and generate a new one?'))
return;
try {
const { gallery: updated } = await photosApi.rotateShareToken(id);
setGallery(updated);
toast.success(es ? 'Nuevo enlace generado' : 'New share link generated');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed');
}
};
const deletePhoto = async (photoId: string): Promise<boolean> => {
if (!confirm(es ? '¿Eliminar esta foto?' : 'Delete this photo?')) return false;
try {
await photosApi.deletePhoto(photoId);
setPhotos((prev) => prev.filter((p) => p.id !== photoId));
toast.success(es ? 'Foto eliminada' : 'Photo deleted');
return true;
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete');
return false;
}
};
const retryPhoto = async (photoId: string) => {
try {
const { photo } = await photosApi.retryPhoto(photoId);
setPhotos((prev) => prev.map((p) => (p.id === photoId ? photo : p)));
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to retry');
}
};
const deleteGallery = async () => {
if (!confirm(es ? '¿Eliminar toda la galería y sus fotos?' : 'Delete the whole gallery and its photos?')) return;
try {
await photosApi.deleteGallery(id);
toast.success(es ? 'Galería eliminada' : 'Gallery deleted');
router.push('/admin/photos');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete');
}
};
// HTML5 drag-and-drop reorder; persisted on drop.
const onDropReorder = async (targetId: string) => {
if (!dragId || dragId === targetId) return;
const ids = photos.map((p) => p.id);
const from = ids.indexOf(dragId);
const to = ids.indexOf(targetId);
if (from < 0 || to < 0) return;
ids.splice(to, 0, ids.splice(from, 1)[0]);
const reordered = ids
.map((pid) => photos.find((p) => p.id === pid))
.filter((p): p is Photo => !!p);
setPhotos(reordered);
setDragId(null);
try {
await photosApi.reorderPhotos(id, ids);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to save order');
load();
}
};
if (loading || !gallery) {
return (
<div>
<Skeleton className="h-8 w-64 mb-6" />
<ImageGridSkeleton />
</div>
);
}
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 openLightboxFor = (photoId: string) => {
const idx = readyPhotos.findIndex((p) => p.id === photoId);
if (idx >= 0) setLightboxIndex(idx);
};
return (
<div
onDragOver={(e) => {
if (e.dataTransfer.types.includes('Files')) e.preventDefault();
}}
onDrop={(e) => {
if (e.dataTransfer.files.length > 0) {
e.preventDefault();
handleUpload(e.dataTransfer.files);
}
}}
>
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<div className="flex items-center gap-3 min-w-0">
<Link href="/admin/photos" className="text-gray-400 hover:text-gray-600">
<ArrowLeftIcon className="w-6 h-6" />
</Link>
<h1 className="text-2xl font-bold text-primary-dark truncate">
{es && gallery.titleEs ? gallery.titleEs : gallery.title}
</h1>
<VisibilityBadge visibility={gallery.visibility} />
</div>
<div className="flex items-center gap-2">
<Button onClick={() => fileInputRef.current?.click()}>
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
{es ? 'Subir fotos' : 'Upload photos'}
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp,image/heic,.heic"
multiple
onChange={(e) => e.target.files && handleUpload(e.target.files)}
className="hidden"
/>
</div>
</div>
{/* Settings */}
<Card className="p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium mb-1">{es ? 'Visibilidad' : 'Visibility'}</label>
<select
value={gallery.visibility}
onChange={(e) =>
saveField(
{ visibility: e.target.value as GalleryVisibility },
es ? 'Visibilidad actualizada' : 'Visibility updated'
)
}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="private">{es ? 'Privada (solo admins)' : 'Private (admins only)'}</option>
<option value="public">{es ? 'Pública (listada)' : 'Public (listed)'}</option>
<option value="link">{es ? 'Solo con enlace' : 'Link only'}</option>
<option value="ticket">{es ? 'Con entrada del evento' : 'Ticket holders of the event'}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">{es ? 'Evento vinculado' : 'Linked event'}</label>
<select
value={gallery.eventId || ''}
onChange={(e) =>
saveField({ eventId: e.target.value }, es ? 'Evento actualizado' : 'Event updated')
}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">{es ? 'Ninguno' : 'None'}</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
{gallery.visibility === 'ticket' && !gallery.eventId && (
<p className="text-xs text-red-600 mt-1">
{es
? 'El modo "con entrada" necesita un evento vinculado'
: 'Ticket mode needs a linked event'}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1">{es ? 'Enlace para compartir' : 'Share link'}</label>
<div className="flex gap-2">
<input
type="text"
readOnly
value={gallery.shareUrl || ''}
className="flex-1 min-w-0 px-3 py-2 text-sm border rounded-btn bg-gray-50"
/>
<Button size="sm" onClick={copyShareLink} title={es ? 'Copiar' : 'Copy'}>
{copied ? <CheckIcon className="w-4 h-4" /> : <LinkIcon className="w-4 h-4" />}
</Button>
<Button size="sm" variant="outline" onClick={rotateToken} title={es ? 'Regenerar' : 'Regenerate'}>
<ArrowPathIcon className="w-4 h-4" />
</Button>
</div>
{gallery.event && (
<p className="text-xs text-gray-500 mt-1">
{es
? 'Publicada en la página del evento: '
: 'Published on the event page: '}
<span className="font-mono">/events/{gallery.event.slug}/gallery</span>
</p>
)}
</div>
</div>
</Card>
{processingCount > 0 && (
<p className="text-sm text-gray-600 mb-4">
{es
? `Procesando ${processingCount} foto(s)…`
: `Processing ${processingCount} photo(s)…`}
</p>
)}
{/* Photo grid */}
{photos.length === 0 ? (
<Card className="p-12 text-center">
<ArrowUpTrayIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<p className="text-gray-500">
{es
? 'Arrastra fotos aquí o usa el botón de subir'
: 'Drag photos here or use the upload button'}
</p>
</Card>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{photos.map((photo) => (
<Card
key={photo.id}
draggable
onDragStart={() => setDragId(photo.id)}
onDragOver={(e) => {
if (dragId) e.preventDefault();
}}
onDrop={(e) => {
if (dragId) {
e.preventDefault();
e.stopPropagation();
onDropReorder(photo.id);
}
}}
className={`group relative overflow-hidden aspect-square cursor-grab ${
dragId === photo.id ? 'opacity-50' : ''
}`}
>
{photo.status === 'ready' && photo.urls.thumb ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photo.urls.thumb}
alt=""
className="w-full h-full object-cover cursor-pointer"
draggable={false}
onClick={() => openLightboxFor(photo.id)}
/>
) : photo.status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-red-50 text-red-600 p-2 text-center">
<ExclamationTriangleIcon className="w-8 h-8 mb-1" />
<span className="text-xs line-clamp-3">{photo.lastError || 'Failed'}</span>
</div>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-100">
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full" />
</div>
)}
{gallery.coverPhotoId === photo.id && (
<StarIconSolid className="absolute top-2 left-2 w-5 h-5 text-primary-yellow drop-shadow" />
)}
<div className="absolute inset-x-0 bottom-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 py-1.5">
{photo.status === 'ready' && (
<button
onClick={() =>
saveField({ coverPhotoId: photo.id }, es ? 'Portada actualizada' : 'Cover updated')
}
className="p-1.5 text-white hover:text-primary-yellow"
title={es ? 'Usar como portada' : 'Set as cover'}
>
<StarIcon className="w-5 h-5" />
</button>
)}
{photo.status === 'failed' && (
<button
onClick={() => retryPhoto(photo.id)}
className="p-1.5 text-white hover:text-primary-yellow"
title={es ? 'Reintentar' : 'Retry'}
>
<ArrowPathIcon className="w-5 h-5" />
</button>
)}
<button
onClick={() => deletePhoto(photo.id)}
className="p-1.5 text-white hover:text-red-400"
title={es ? 'Eliminar' : 'Delete'}
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
</Card>
))}
</div>
)}
{/* Danger zone */}
<div className="mt-10 pt-6 border-t border-secondary-light-gray flex justify-end">
<Button variant="danger" onClick={deleteGallery}>
<TrashIcon className="w-5 h-5 mr-2" />
{es ? 'Eliminar galería' : 'Delete gallery'}
</Button>
</div>
{/* Lightbox with admin actions */}
{lightboxIndex !== null && lightboxItems.length > 0 && (
<Lightbox
items={lightboxItems}
index={Math.min(lightboxIndex, lightboxItems.length - 1)}
onClose={() => setLightboxIndex(null)}
onNavigate={setLightboxIndex}
renderActions={(item) => (
<>
<button
onClick={() =>
saveField({ coverPhotoId: item.id }, es ? 'Portada actualizada' : 'Cover updated')
}
className="text-white hover:text-primary-yellow"
title={es ? 'Usar como portada' : 'Set as cover'}
>
{gallery.coverPhotoId === item.id ? (
<StarIconSolid className="w-7 h-7 text-primary-yellow" />
) : (
<StarIcon className="w-7 h-7" />
)}
</button>
<button
onClick={async () => {
const before = readyPhotos.length;
const deleted = await deletePhoto(item.id);
if (!deleted) return;
if (before <= 1) setLightboxIndex(null);
else setLightboxIndex((i) => (i === null ? null : Math.max(0, Math.min(i, before - 2))));
}}
className="text-white hover:text-red-400"
title={es ? 'Eliminar' : 'Delete'}
>
<TrashIcon className="w-7 h-7" />
</button>
</>
)}
/>
)}
{/* Uploader panel (Google Drive style) */}
{uploads.length > 0 && (
<div className="fixed bottom-4 right-4 z-40 w-96 max-w-[calc(100vw-2rem)]">
<div className="bg-white rounded-card shadow-card-hover border border-secondary-light-gray overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-primary-dark text-white">
<p className="text-sm font-medium">
{activeUploads > 0
? es
? `Subiendo ${uploads.length - activeUploads}/${uploads.length}`
: `Uploading ${uploads.length - activeUploads}/${uploads.length}`
: es
? `${uploads.length} subida(s) completada(s)`
: `${uploads.length} upload(s) complete`}
</p>
<div className="flex items-center gap-1">
<button
onClick={() => setPanelCollapsed((c) => !c)}
className="p-1 hover:text-primary-yellow"
aria-label={panelCollapsed ? 'Expand' : 'Collapse'}
>
{panelCollapsed ? <ChevronUpIcon className="w-5 h-5" /> : <ChevronDownIcon className="w-5 h-5" />}
</button>
{activeUploads === 0 && (
<button
onClick={() => setUploads([])}
className="p-1 hover:text-primary-yellow"
aria-label="Close"
>
<XMarkIcon className="w-5 h-5" />
</button>
)}
</div>
</div>
{!panelCollapsed && (
<ul className="max-h-72 overflow-y-auto divide-y divide-secondary-light-gray">
{uploads.map((u) => {
const ds = displayStatus(u);
return (
<li key={u.key} className="px-4 py-2.5">
<div className="flex items-center gap-3">
<PhotoIcon className="w-5 h-5 text-gray-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm text-primary-dark truncate" title={u.name}>
{u.name}
</p>
<p className="text-xs text-gray-500">
{ds.state === 'uploading' && `${Math.round(u.progress * 100)}% · ${formatBytes(u.sizeBytes)}`}
{ds.state === 'queued' && (es ? 'En cola' : 'Queued')}
{ds.state === 'processing' && (es ? 'Procesando…' : 'Processing…')}
{ds.state === 'done' && formatBytes(u.sizeBytes)}
{ds.state === 'error' && (
<span className="text-red-600" title={ds.error}>
{ds.error}
</span>
)}
</p>
</div>
<span className="shrink-0">
{ds.state === 'done' && <CheckCircleIcon className="w-6 h-6 text-green-600" />}
{ds.state === 'error' && <ExclamationCircleIcon className="w-6 h-6 text-red-600" />}
{ds.state === 'processing' && (
<div className="animate-spin w-5 h-5 border-2 border-primary-yellow border-t-transparent rounded-full" />
)}
{ds.state === 'uploading' && (
<span className="text-xs text-gray-600 tabular-nums">{Math.round(u.progress * 100)}%</span>
)}
</span>
</div>
{ds.state === 'uploading' && (
<div className="mt-1.5 h-1 rounded-full bg-secondary-gray overflow-hidden">
<div
className="h-full bg-primary-yellow transition-[width] duration-200"
style={{ width: `${Math.round(u.progress * 100)}%` }}
/>
</div>
)}
</li>
);
})}
</ul>
)}
</div>
</div>
)}
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { photosApi, eventsApi, PhotoGallery, Event, GalleryVisibility } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Skeleton, ImageGridSkeleton } from '@/components/ui/Skeleton';
import VisibilityBadge from './VisibilityBadge';
import {
CameraIcon,
PlusIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
export default function AdminPhotosPage() {
const { locale } = useLanguage();
const [galleries, setGalleries] = useState<PhotoGallery[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState({
title: '',
titleEs: '',
eventId: '',
visibility: 'private' as GalleryVisibility,
});
useEffect(() => {
Promise.all([photosApi.getGalleries(), eventsApi.getAll()])
.then(([g, e]) => {
setGalleries(g.galleries);
setEvents(e.events);
})
.catch(() => toast.error(locale === 'es' ? 'Error al cargar galerías' : 'Failed to load galleries'))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.title.trim()) return;
setCreating(true);
try {
const { gallery } = await photosApi.createGallery({
title: form.title.trim(),
titleEs: form.titleEs.trim() || undefined,
eventId: form.eventId || undefined,
visibility: form.visibility,
});
setGalleries((prev) => [gallery, ...prev]);
setShowCreate(false);
setForm({ title: '', titleEs: '', eventId: '', visibility: 'private' });
toast.success(locale === 'es' ? 'Galería creada' : 'Gallery created');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create gallery');
} finally {
setCreating(false);
}
};
if (loading) {
return (
<div>
<div className="flex items-center justify-between mb-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
</div>
<ImageGridSkeleton />
</div>
);
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-primary-dark">
{locale === 'es' ? 'Fotos de Eventos' : 'Event Photos'}
</h1>
<Button onClick={() => setShowCreate(true)}>
<PlusIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Nueva Galería' : 'New Gallery'}
</Button>
</div>
{galleries.length === 0 ? (
<Card className="p-12 text-center">
<CameraIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
<h3 className="text-lg font-semibold text-gray-600 mb-2">
{locale === 'es' ? 'Sin galerías todavía' : 'No galleries yet'}
</h3>
<p className="text-gray-500 mb-4">
{locale === 'es'
? 'Crea una galería para compartir las fotos de un evento'
: 'Create a gallery to share photos from a past event'}
</p>
<Button onClick={() => setShowCreate(true)}>
<PlusIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Crear la primera' : 'Create the first one'}
</Button>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{galleries.map((g) => (
<Link key={g.id} href={`/admin/photos/${g.id}`}>
<Card className="overflow-hidden hover:shadow-card-hover transition-shadow cursor-pointer 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" />
) : (
<CameraIcon className="w-12 h-12 text-gray-300" />
)}
</div>
<div className="p-4">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-primary-dark truncate">
{locale === 'es' && g.titleEs ? g.titleEs : g.title}
</h3>
<VisibilityBadge visibility={g.visibility} />
</div>
<p className="text-sm text-gray-500 mt-1">
{g.photoCount} {locale === 'es' ? 'fotos' : 'photos'}
{g.event && <> · {locale === 'es' && g.event.titleEs ? g.event.titleEs : g.event.title}</>}
</p>
</div>
</Card>
</Link>
))}
</div>
)}
{showCreate && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-primary-dark">
{locale === 'es' ? 'Nueva Galería' : 'New Gallery'}
</h2>
<button onClick={() => setShowCreate(false)} className="text-gray-400 hover:text-gray-600">
<XMarkIcon className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Título (inglés)' : 'Title (English)'} *
</label>
<input
type="text"
required
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
placeholder="June Language Exchange"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Título (español)' : 'Title (Spanish)'}
</label>
<input
type="text"
value={form.titleEs}
onChange={(e) => setForm({ ...form, titleEs: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
placeholder="Intercambio de Junio"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Evento vinculado' : 'Linked event'}
</label>
<select
value={form.eventId}
onChange={(e) => setForm({ ...form, eventId: e.target.value })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="">{locale === 'es' ? 'Ninguno' : 'None'}</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{locale === 'es' ? 'Visibilidad' : 'Visibility'}
</label>
<select
value={form.visibility}
onChange={(e) => setForm({ ...form, visibility: e.target.value as GalleryVisibility })}
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray"
>
<option value="private">{locale === 'es' ? 'Privada (solo admins)' : 'Private (admins only)'}</option>
<option value="public">{locale === 'es' ? 'Pública (listada)' : 'Public (listed)'}</option>
<option value="link">{locale === 'es' ? 'Solo con enlace' : 'Link only'}</option>
<option value="ticket">
{locale === 'es' ? 'Con entrada del evento' : 'Ticket holders of the event'}
</option>
</select>
</div>
<div className="flex gap-2 justify-end pt-2">
<Button type="button" variant="outline" onClick={() => setShowCreate(false)}>
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
<Button type="submit" isLoading={creating}>
{locale === 'es' ? 'Crear' : 'Create'}
</Button>
</div>
</form>
</Card>
</div>
)}
</div>
);
}
+39 -2
View File
@@ -2,6 +2,26 @@ import { MetadataRoute } from 'next';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003';
interface SitemapPhotoGallery {
slug: string;
updatedAt: string;
event?: { slug: string };
}
/** Public photo galleries from the photo-api service (public visibility only). */
async function getPublicPhotoGalleries(): Promise<SitemapPhotoGallery[]> {
try {
const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, {
next: { revalidate: 3600 },
});
if (!res.ok) return [];
return ((await res.json()).galleries as SitemapPhotoGallery[]) || [];
} catch {
return [];
}
}
interface SitemapEvent {
id: string;
@@ -41,7 +61,10 @@ async function getIndexableEvents(): Promise<SitemapEvent[]> {
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const events = await getIndexableEvents();
const [events, photoGalleries] = await Promise.all([
getIndexableEvents(),
getPublicPhotoGalleries(),
]);
const now = new Date();
// Static pages
@@ -88,6 +111,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'monthly',
priority: 0.6,
},
{
url: `${siteUrl}/photos`,
lastModified: now,
changeFrequency: 'weekly',
priority: 0.6,
},
// Legal pages
{
url: `${siteUrl}/legal/terms-policy`,
@@ -120,5 +149,13 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
};
});
return [...staticPages, ...eventPages];
const photoPages: MetadataRoute.Sitemap = photoGalleries.map((g) => ({
// Event-linked galleries live under the event's URL.
url: g.event ? `${siteUrl}/events/${g.event.slug}/gallery` : `${siteUrl}/photos/${g.slug}`,
lastModified: g.updatedAt ? new Date(g.updatedAt) : now,
changeFrequency: 'monthly' as const,
priority: 0.5,
}));
return [...staticPages, ...eventPages, ...photoPages];
}
+145
View File
@@ -0,0 +1,145 @@
'use client';
import { useEffect, useCallback, useState } from 'react';
import {
XMarkIcon,
ChevronLeftIcon,
ChevronRightIcon,
ArrowDownTrayIcon,
} from '@heroicons/react/24/outline';
export interface LightboxItem {
id: string;
previewUrl: string;
downloadUrl: string;
filename?: string;
}
interface LightboxProps {
items: LightboxItem[];
index: number;
onClose: () => void;
onNavigate: (index: number) => void;
/** Extra per-item action buttons rendered in the top bar (admin use). */
renderActions?: (item: LightboxItem, index: number) => React.ReactNode;
}
/**
* 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) {
const [touchStartX, setTouchStartX] = useState<number | null>(null);
const item = items[index];
const prev = useCallback(() => {
onNavigate(index > 0 ? index - 1 : items.length - 1);
}, [index, items.length, onNavigate]);
const next = useCallback(() => {
onNavigate(index < items.length - 1 ? index + 1 : 0);
}, [index, items.length, onNavigate]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') prev();
if (e.key === 'ArrowRight') next();
};
window.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = '';
};
}, [onClose, prev, next]);
if (!item) return null;
return (
<div
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
onClick={onClose}
onTouchStart={(e) => setTouchStartX(e.touches[0].clientX)}
onTouchEnd={(e) => {
if (touchStartX === null) return;
const dx = e.changedTouches[0].clientX - touchStartX;
if (dx > 60) prev();
if (dx < -60) next();
setTouchStartX(null);
}}
>
<button
className="absolute top-4 right-4 text-white hover:text-gray-300 z-10 p-2 -m-2"
onClick={onClose}
aria-label="Close"
>
<XMarkIcon className="w-8 h-8" />
</button>
<div
className="absolute top-4 left-4 z-10 flex items-center gap-4"
onClick={(e) => e.stopPropagation()}
>
<a
href={item.downloadUrl}
download={item.filename || true}
className="text-white hover:text-gray-300"
aria-label="Download"
>
<ArrowDownTrayIcon className="w-7 h-7" />
</a>
{renderActions?.(item, index)}
</div>
{items.length > 1 && (
<>
<button
className="absolute left-2 md:left-4 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
prev();
}}
aria-label="Previous"
>
<ChevronLeftIcon className="w-9 h-9" />
</button>
<button
className="absolute right-2 md:right-4 text-white/80 hover:text-white z-10 p-2"
onClick={(e) => {
e.stopPropagation();
next();
}}
aria-label="Next"
>
<ChevronRightIcon className="w-9 h-9" />
</button>
</>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.previewUrl}
alt=""
className="max-w-[95vw] max-h-[90vh] object-contain select-none"
onClick={(e) => e.stopPropagation()}
draggable={false}
/>
<div className="absolute bottom-3 inset-x-0 text-center text-white/70 text-sm">
{index + 1} / {items.length}
</div>
{/* Preload neighbours so prev/next feels instant. */}
<div className="hidden" aria-hidden>
{items.length > 1 && (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={items[(index + 1) % items.length].previewUrl} alt="" />
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={items[(index - 1 + items.length) % items.length].previewUrl} alt="" />
</>
)}
</div>
</div>
);
}
+1
View File
@@ -275,6 +275,7 @@
"contacts": "Messages",
"emails": "Emails",
"gallery": "Gallery",
"photos": "Photos",
"settings": "Settings"
},
"events": {
+1
View File
@@ -275,6 +275,7 @@
"contacts": "Mensajes",
"emails": "Emails",
"gallery": "Galería",
"photos": "Fotos",
"settings": "Configuración"
},
"events": {
+8
View File
@@ -17,3 +17,11 @@ export { siteSettingsApi } from './siteSettings';
export { legalSettingsApi } from './legalSettings';
export { legalPagesApi } from './legalPages';
export { faqApi } from './faq';
export { photosApi } from './photos';
export type {
Photo,
PhotoGallery,
PhotoGalleryEvent,
GalleryVisibility,
CreateGalleryInput,
} from './photos';
+176
View File
@@ -0,0 +1,176 @@
import { fetchApi, API_BASE, getToken } from './client';
// Client for the standalone photo-api Go service (photo-api/), reachable
// under /api/photos via the Next rewrite (dev) or nginx (prod).
export type GalleryVisibility = 'public' | 'private' | 'link' | 'ticket';
export interface PhotoGalleryEvent {
id: string;
slug: string;
title: string;
titleEs?: string;
startDatetime: string;
status: string;
}
export interface PhotoGallery {
id: string;
slug: string;
title: string;
titleEs?: string;
description?: string;
descriptionEs?: string;
eventId?: string;
visibility: GalleryVisibility;
coverPhotoId?: string;
photoCount: number;
coverUrl?: string;
createdAt: string;
updatedAt: string;
event?: PhotoGalleryEvent;
// Present on admin responses only:
shareToken?: string;
shareUrl?: string;
}
export interface Photo {
id: string;
galleryId: string;
position: number;
originalFilename?: string;
contentType: string;
sizeBytes: number;
width?: number;
height?: number;
takenAt?: string;
status: 'queued' | 'processing' | 'ready' | 'failed';
lastError?: string;
createdAt: string;
urls: {
thumb?: string;
preview?: string;
original: string;
};
}
export interface CreateGalleryInput {
title: string;
titleEs?: string;
description?: string;
descriptionEs?: string;
eventId?: string;
visibility?: GalleryVisibility;
}
export const photosApi = {
// Admin (requires admin/organizer token)
createGallery: (data: CreateGalleryInput) =>
fetchApi<{ gallery: PhotoGallery }>('/api/photos/galleries', {
method: 'POST',
body: JSON.stringify(data),
}),
getGalleries: (eventId?: string) => {
const query = eventId ? `?eventId=${encodeURIComponent(eventId)}` : '';
return fetchApi<{ galleries: PhotoGallery[] }>(`/api/photos/galleries${query}`);
},
getGallery: (id: string) =>
fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(`/api/photos/galleries/${id}`),
updateGallery: (id: string, data: Partial<CreateGalleryInput> & { coverPhotoId?: string }) =>
fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
deleteGallery: (id: string) =>
fetchApi<{ message: string }>(`/api/photos/galleries/${id}`, { method: 'DELETE' }),
rotateShareToken: (id: string) =>
fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}/share-token`, {
method: 'POST',
}),
uploadPhoto: async (galleryId: string, file: File) => {
const token = getToken();
const formData = new FormData();
formData.append('files', file);
const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Upload failed' }));
throw new Error(errorData.error || 'Upload failed');
}
return res.json() as Promise<{ photos: Photo[] }>;
},
/**
* Upload with byte-level progress (0..1). fetch() cannot report upload
* progress, so this uses XMLHttpRequest; used by the admin uploader panel.
*/
uploadPhotoWithProgress: (
galleryId: string,
file: File,
onProgress: (fraction: number) => void
) =>
new Promise<{ photos: Photo[] }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}/api/photos/galleries/${galleryId}/photos`);
const token = getToken();
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total);
};
xhr.onload = () => {
try {
const body = JSON.parse(xhr.responseText || '{}');
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
else reject(new Error(body.error || `Upload failed (${xhr.status})`));
} catch {
reject(new Error(`Upload failed (${xhr.status})`));
}
};
xhr.onerror = () => reject(new Error('Network error during upload'));
xhr.onabort = () => reject(new Error('Upload cancelled'));
const formData = new FormData();
formData.append('files', file);
xhr.send(formData);
}),
reorderPhotos: (galleryId: string, photoIds: string[]) =>
fetchApi<{ message: string }>(`/api/photos/galleries/${galleryId}/order`, {
method: 'PATCH',
body: JSON.stringify({ photoIds }),
}),
deletePhoto: (photoId: string) =>
fetchApi<{ message: string }>(`/api/photos/photos/${photoId}`, { method: 'DELETE' }),
retryPhoto: (photoId: string) =>
fetchApi<{ photo: Photo }>(`/api/photos/photos/${photoId}/retry`, { method: 'POST' }),
// Viewer (anonymous or member token; share token via query param)
getPublicGalleries: () =>
fetchApi<{ galleries: PhotoGallery[] }>('/api/photos/public/galleries'),
getPublicGallery: (slug: string, token?: string) => {
const query = token ? `?token=${encodeURIComponent(token)}` : '';
return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(
`/api/photos/public/galleries/${encodeURIComponent(slug)}${query}`
);
},
/** Newest gallery linked to an event; backs /events/[slug]/gallery. */
getEventGallery: (eventSlug: string, token?: string) => {
const query = token ? `?token=${encodeURIComponent(token)}` : '';
return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(
`/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery${query}`
);
},
};
+18 -4
View File
@@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -18,9 +22,19 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next-build/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}