'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, DocumentDuplicateIcon, 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 // 'duplicate' is terminal: the gallery already held these bytes, so nothing // was stored and no new tile appears in the grid. status: 'queued' | 'uploading' | 'processing' | 'error' | 'duplicate'; 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(null); const [photos, setPhotos] = useState([]); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [copied, setCopied] = useState(false); const [dragId, setDragId] = useState(null); const [lightboxIndex, setLightboxIndex] = useState(null); const fileInputRef = useRef(null); // Uploader panel state (Google Drive style: one row per file). const [uploads, setUploads] = useState([]); 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) => { 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 }) ); // A duplicate echoes back a photo already in the grid, so merge by id // instead of appending (`duplicate` is an upload outcome, not photo // state, so it is dropped here). setPhotos((prev) => { const byId = new Map(prev.map((p) => [p.id, p])); added.forEach(({ duplicate: _duplicate, ...photo }) => byId.set(photo.id, photo)); return Array.from(byId.values()); }); patchUpload(key, { status: added[0]?.duplicate ? 'duplicate' : '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. 'duplicate' // is terminal and never reconciled — its photo was already there. 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[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 => { 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 (
); } 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, thumbUrl: p.urls.thumb, })); const openLightboxFor = (photoId: string) => { const idx = readyPhotos.findIndex((p) => p.id === photoId); if (idx >= 0) setLightboxIndex(idx); }; return (
{ if (e.dataTransfer.types.includes('Files')) e.preventDefault(); }} onDrop={(e) => { if (e.dataTransfer.files.length > 0) { e.preventDefault(); handleUpload(e.dataTransfer.files); } }} >

{es && gallery.titleEs ? gallery.titleEs : gallery.title}

e.target.files && handleUpload(e.target.files)} className="hidden" />
{/* Settings */}
{gallery.visibility === 'ticket' && !gallery.eventId && (

{es ? 'El modo "con entrada" necesita un evento vinculado' : 'Ticket mode needs a linked event'}

)}
{gallery.event && (

{es ? 'Publicada en la página del evento: ' : 'Published on the event page: '} /events/{gallery.event.slug}/gallery

)}
{processingCount > 0 && (

{es ? `Procesando ${processingCount} foto(s)…` : `Processing ${processingCount} photo(s)…`}

)} {/* Photo grid */} {photos.length === 0 ? (

{es ? 'Arrastra fotos aquí o usa el botón de subir' : 'Drag photos here or use the upload button'}

) : (
{photos.map((photo) => ( 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 openLightboxFor(photo.id)} /> ) : photo.status === 'failed' ? (
{photo.lastError || 'Failed'}
) : (
)} {gallery.coverPhotoId === photo.id && ( )}
{photo.status === 'ready' && ( )} {photo.status === 'failed' && ( )}
))}
)} {/* Danger zone */}
{/* Lightbox with admin actions */} {lightboxIndex !== null && lightboxItems.length > 0 && ( setLightboxIndex(null)} onNavigate={setLightboxIndex} renderActions={(item) => ( <> )} /> )} {/* Uploader panel (Google Drive style) */} {uploads.length > 0 && (

{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`}

{activeUploads === 0 && ( )}
{!panelCollapsed && (
    {uploads.map((u) => { const ds = displayStatus(u); return (
  • {u.name}

    {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 === 'duplicate' && ( {es ? 'Ya está en esta galería' : 'Already in this gallery'} )} {ds.state === 'error' && ( {ds.error} )}

    {ds.state === 'done' && } {ds.state === 'duplicate' && ( )} {ds.state === 'error' && } {ds.state === 'processing' && (
    )} {ds.state === 'uploading' && ( {Math.round(u.progress * 100)}% )}
    {ds.state === 'uploading' && (
    )}
  • ); })}
)}
)}
); }