327 lines
15 KiB
TypeScript
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;
|