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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user