Uploads skip per-gallery duplicates, checksums can be backfilled, and STORAGE_BACKEND plus sync tooling make switching storage backends safe.
642 lines
25 KiB
TypeScript
642 lines
25 KiB
TypeScript
'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<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 })
|
|
);
|
|
// 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<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,
|
|
thumbUrl: p.urls.thumb,
|
|
}));
|
|
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 === 'duplicate' && (
|
|
<span className="text-amber-600">
|
|
{es ? 'Ya está en esta galería' : 'Already in this gallery'}
|
|
</span>
|
|
)}
|
|
{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 === 'duplicate' && (
|
|
<DocumentDuplicateIcon className="w-6 h-6 text-amber-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>
|
|
);
|
|
}
|