almeira-web/src/components/dashboard/MediaGridSelector.tsx

327 lines
15 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { ImageIcon, Video, X, Search, CheckCircle2, UploadCloud, Loader2 } from 'lucide-react';
import { databases, APPWRITE_ALMEIRA_CONFIG, endpoint, projectId } from '@/lib/appwrite';
import { uploadMediaDocumentServer, listMediaServer, getMediaServer } from '@/app/actions/appwrite';
/** Utility to generate frontend Appwrite image previews */
export function getAppwriteImageUrl(fileId: string, bucketId?: string, width = 400, height = 250, isVideo = false) {
if (!fileId) return '';
const finalBucketId = bucketId || APPWRITE_ALMEIRA_CONFIG.mediaBucketId;
if (isVideo) {
return `${endpoint}/storage/buckets/${finalBucketId}/files/${fileId}/view?project=${projectId}`;
}
return `${endpoint}/storage/buckets/${finalBucketId}/files/${fileId}/view?project=${projectId}&width=${width}&height=${height}&quality=80`;
}
interface MediaGridSelectorProps {
value?: string;
onChange: (fileId: string, url?: string, tipo?: string) => void;
label?: string;
mode?: 'field' | 'modal';
isOpen?: boolean;
onClose?: () => void;
}
export const MediaGridSelector = ({
value,
onChange,
label = 'Seleccionar Medio',
mode = 'field',
isOpen: externalOpen,
onClose
}: MediaGridSelectorProps) => {
const [isOpen, setIsOpen] = useState(mode === 'modal' ? !!externalOpen : false);
const [mediaItems, setMediaItems] = useState<any[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (mode === 'modal' && externalOpen !== undefined) setIsOpen(externalOpen);
}, [mode, externalOpen]);
useEffect(() => {
if (isOpen) fetchMedia();
}, [isOpen]);
const fetchMedia = async (more = false, search = '') => {
setIsLoading(true);
try {
const currentOffset = more ? mediaItems.length : 0;
const res = await listMediaServer(search, currentOffset);
if (res.success && res.data) {
if (more) {
setMediaItems(prev => [...prev, ...res.data]);
} else {
setMediaItems(res.data);
}
setHasMore(res.data.length === 20);
} else {
setHasMore(false);
}
} catch (error) {
console.error('Error fetching media:', error);
} finally {
setIsLoading(false);
}
};
// Handle Search Debouncing
useEffect(() => {
if (!isOpen) return;
const timer = setTimeout(() => {
fetchMedia(false, searchTerm);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm, isOpen]);
useEffect(() => {
// If we have a value but haven't loaded its document, fetch just that one
if (value && mode === 'field' && !mediaItems.some(m => m.$id === value)) {
getMediaServer(value)
.then(res => {
if (res.success && res.data) {
setMediaItems(prev => {
if (prev.some(m => m.$id === value)) return prev;
return [...prev, res.data];
});
}
}).catch(err => console.error("Error resolver media thumbnail:", err));
}
}, [value, mode]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
const formData = new FormData();
formData.append('files', file);
const res = await uploadMediaDocumentServer(formData);
if (!res.success || !res.data || res.data.length === 0) {
throw new Error(res.error || "Error from server edge while uploading media.");
}
const doc = res.data[0];
setMediaItems(prev => [doc, ...prev]);
const isVideo = doc.tipo === 'video';
const url = getAppwriteImageUrl(doc.file_id, doc.bucket_id, 1200, 800, isVideo);
onChange(doc.$id, url, doc.tipo);
closeModal();
} catch (error) {
console.error("Error upload:", error);
alert("Error al subir el archivo. Revisa que el perfil tenga el scope files.write.");
} finally {
setIsUploading(false);
}
};
const closeModal = useCallback(() => {
setIsOpen(false);
if (onClose) onClose();
}, [onClose]);
const filteredItems = mediaItems;
// Render modal structure
const renderModal = () => {
if (!isOpen) return null;
const popup = (
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-4 md:p-8 animate-fade-in">
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-md" onClick={closeModal}></div>
<div className="relative w-full max-w-5xl max-h-[85vh] bg-slate-900 border border-slate-800 rounded-3xl shadow-2xl flex flex-col overflow-hidden animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-900/50">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-500/10 rounded-xl text-purple-400 border border-purple-500/20">
<ImageIcon size={20} />
</div>
<div>
<h3 className="text-lg font-black uppercase tracking-tighter text-white">Biblioteca de Medios</h3>
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Selecciona o sube un archivo</p>
</div>
</div>
<button onClick={closeModal} className="p-2 hover:bg-slate-800 rounded-xl text-slate-500 hover:text-white transition-all">
<X size={20} />
</button>
</div>
{/* Sub-header (Search + Upload) */}
<div className="p-6 pb-2 flex flex-col sm:flex-row gap-4 items-center justify-between">
<div className="relative group w-full sm:w-2/3">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-500 group-focus-within:text-cyan-500 transition-colors" size={18} />
<input
type="text"
placeholder="Buscar imagen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-2xl pl-12 pr-6 py-4 text-white focus:outline-none focus:border-cyan-500 transition-all font-medium"
/>
</div>
<div className="w-full sm:w-1/3 flex justify-end">
<input type="file" accept="image/*,video/*" className="hidden" ref={fileInputRef} onChange={handleUpload}/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full sm:w-auto flex items-center justify-center gap-2 bg-gradient-to-r from-purple-600 to-pink-500 hover:from-purple-500 hover:to-pink-400 text-white px-6 py-4 rounded-2xl font-black uppercase tracking-widest text-xs transition-all shadow-[0_0_20px_rgba(168,85,247,0.3)] disabled:opacity-50"
>
{isUploading ? <Loader2 size={16} className="animate-spin" /> : <UploadCloud size={16} />}
{isUploading ? 'Subiendo...' : 'Subir Nuevo'}
</button>
</div>
</div>
{/* Content Grid */}
<div className="flex-1 overflow-y-auto p-6 pt-2 custom-scrollbar">
{isLoading ? (
<div className="h-64 flex flex-col items-center justify-center gap-4">
<Loader2 size={32} className="text-cyan-500 animate-spin" />
<span className="text-xs font-black uppercase tracking-widest text-slate-500">Cargando Galería...</span>
</div>
) : filteredItems.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{filteredItems.map((item) => {
const isVideo = item.tipo === 'video';
const url = getAppwriteImageUrl(item.file_id, item.bucket_id, 300, 300, isVideo);
return (
<div
key={item.$id}
onClick={() => {
const selectedUrl = getAppwriteImageUrl(item.file_id, item.bucket_id, 1200, 800, isVideo);
onChange(item.$id, selectedUrl, item.tipo);
if (mode !== 'modal') setIsOpen(false);
closeModal();
}}
className={`group relative aspect-square rounded-2xl border-2 transition-all cursor-pointer overflow-hidden bg-slate-950 ${
value === item.file_id ? 'border-cyan-500 shadow-xl shadow-cyan-500/20' : 'border-slate-800 hover:border-slate-600 shadow-lg'
}`}
>
{isVideo ? (
<video src={url} title={item.titulo} className="w-full h-full object-cover transition-transform group-hover:scale-110" onMouseEnter={e => e.currentTarget.play()} onMouseLeave={e => e.currentTarget.pause()} muted loop />
) : (
<img src={url} alt={item.titulo} title={item.titulo} className="w-full h-full object-cover transition-transform group-hover:scale-110" loading="lazy" />
)}
<div className="absolute top-2 left-2 bg-slate-900/80 backdrop-blur-md rounded-lg p-1.5 shadow-lg border border-slate-700/50">
{isVideo ? <Video size={14} className="text-cyan-400" /> : <ImageIcon size={14} className="text-pink-400" />}
</div>
<div className="absolute inset-0 bg-gradient-to-t from-cyan-500/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
{value === item.$id && (
<div className="absolute top-2 right-2 bg-cyan-500 text-white rounded-full p-1 shadow-lg">
<CheckCircle2 size={16} />
</div>
)}
</div>
);
})}
</div>
) : (
<div className="h-48 flex flex-col items-center justify-center text-slate-500 border-2 border-dashed border-slate-800 rounded-3xl gap-2">
<ImageIcon size={32} className="opacity-50" />
<p className="font-bold uppercase tracking-widest text-sm text-slate-400 mt-2">Sin Resultados</p>
</div>
)}
</div>
<div className="p-4 bg-slate-950 border-t border-slate-800 flex justify-between items-center px-8">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">{mediaItems.length} Archivos Disponibles</span>
{hasMore && (
<button
onClick={() => fetchMedia(true, searchTerm)}
disabled={isLoading}
className="text-[10px] font-black uppercase tracking-widest text-cyan-500 hover:text-cyan-400 disabled:opacity-50 transition-colors flex items-center gap-2"
>
{isLoading ? <Loader2 size={12} className="animate-spin" /> : null}
Cargar más resultados ↓
</button>
)}
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(popup, document.body) : popup;
};
if (mode === 'modal') {
return renderModal();
}
// FIELD MODE RENDER (Inline usage in forms)
return (
<div className="space-y-2 w-full">
<div className="flex justify-between items-center mb-1">
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500">
{label}
</label>
{value && (
<button type="button" onClick={() => onChange('', '')} className="text-[9px] font-bold text-pink-500 hover:text-pink-400 transition-colors uppercase">
Quitar Media
</button>
)}
</div>
<div
onClick={() => setIsOpen(true)}
className={`relative aspect-video rounded-2xl border-2 border-dashed transition-all cursor-pointer flex flex-col items-center justify-center gap-2 overflow-hidden bg-slate-950/50 group ${
value ? 'border-cyan-500/50 hover:border-cyan-400 shadow-[0_0_20px_rgba(6,182,212,0.1)]' : 'border-slate-800 hover:border-slate-700 hover:bg-slate-900'
}`}
>
{value ? (
<>
{(() => {
// Si value existe, buscamos su documento para sacar el file_id
const selectedDoc = mediaItems.find(m => m.$id === value);
// Si aún no está en la lista (cargando), mostramos loader genérico
if (!selectedDoc && mediaItems.length > 0) return null;
const isVideo = selectedDoc?.tipo === 'video';
const fileIdToRender = selectedDoc ? selectedDoc.file_id : value;
const bucketIdToRender = selectedDoc ? selectedDoc.bucket_id : undefined;
const fileUrl = getAppwriteImageUrl(fileIdToRender, bucketIdToRender, 800, 450, isVideo);
return isVideo ? (
<video src={fileUrl} className="w-full h-full object-cover" muted loop autoPlay />
) : (
<img src={fileUrl} alt="Selected Preview" className="w-full h-full object-cover" />
);
})()}
<div className="absolute inset-0 bg-slate-900/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<span className="bg-slate-950/80 px-4 py-2 rounded-full text-[10px] font-black uppercase tracking-widest text-cyan-400 border border-cyan-500/30">
Cambiar Imagen ↔
</span>
</div>
<div className="absolute top-3 left-3 bg-cyan-500 text-slate-950 text-[9px] font-black uppercase tracking-widest px-2 py-1 rounded shadow-lg">
Activa
</div>
</>
) : (
<>
<div className="p-4 bg-slate-900 rounded-2xl text-slate-600 group-hover:text-cyan-500 transition-colors border border-slate-800 group-hover:border-cyan-500/30 shadow-inner">
<ImageIcon size={28} />
</div>
<span className="text-[10px] font-black text-slate-500 uppercase tracking-widest mt-1">Explorar Biblioteca de Medios</span>
</>
)}
</div>
{/* Sólo incluir hidden input en modo campo, para evitar conflictos cuando se usa como modal puro */}
{mode === 'field' && label !== 'Logotipo Corporativo' && (
<input type="hidden" name="imagen_destacada_id" value={value || ''} />
)}
{renderModal()}
</div>
);
};
export default MediaGridSelector;