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