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>
222 lines
9.0 KiB
TypeScript
222 lines
9.0 KiB
TypeScript
'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>
|
|
);
|
|
}
|