UNPKG

@gambito-corp/mbs-library

Version:

Librería de componentes React reutilizables - Sistema de diseño modular y escalable

419 lines (370 loc) 17.8 kB
import React, { useState, useEffect, useCallback } from 'react'; import TextAreaField from '../molecules/TextAreaField'; import ImageUploadField from '../molecules/ImageUploadField'; import CategorySelector from '../molecules/CategorySelector'; import Button from '../atoms/Button'; import Alert from '../atoms/Alert'; import FullScreenLoader from '../atoms/FullScreenLoader'; import { useApi } from '../../hooks/useApi'; const FlashcardForm = ({ onFlashcardCreated, categories = [], className = '', ...props }) => { const [formData, setFormData] = useState({ pregunta: '', respuesta: '', url: '', imagen: null, url_respuesta: '', imagen_respuesta: null, selectedCategories: [] }); const [errors, setErrors] = useState({}); const [successMessage, setSuccessMessage] = useState(''); const [aiLoading, setAiLoading] = useState({ pregunta: false, respuesta: false }); const [isAnyAiLoading, setIsAnyAiLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [aiAnimating, setAiAnimating] = useState({ pregunta: false, respuesta: false }); const api = useApi(); // Controlar el estado general de IA useEffect(() => { const anyLoading = aiLoading.pregunta || aiLoading.respuesta; setIsAnyAiLoading(anyLoading); }, [aiLoading.pregunta, aiLoading.respuesta]); // ← Nuevo: Estado general de loading (IA o submit) const isAnyLoading = isAnyAiLoading || isSubmitting; const handleChange = useCallback((field, value) => { if (isSubmitting) return; setFormData(prev => ({ ...prev, [field]: value })); if (errors[field]) { setErrors(prev => ({ ...prev, [field]: null })); } }, [isSubmitting, errors]); // ← Dependencias específicas const handleImageUrlChange = (field, value) => { // ← Prevenir cambios si está enviando if (isSubmitting) return; setFormData(prev => ({ ...prev, [field]: value, [field === 'url' ? 'imagen' : 'imagen_respuesta']: value.trim() ? null : prev[field === 'url' ? 'imagen' : 'imagen_respuesta'] })); }; const handleImageFileChange = (field, file) => { // ← Prevenir cambios si está enviando if (isSubmitting) return; setFormData(prev => ({ ...prev, [field === 'imagen' ? 'imagen' : 'imagen_respuesta']: file, [field === 'imagen' ? 'url' : 'url_respuesta']: file ? '' : prev[field === 'imagen' ? 'url' : 'url_respuesta'] })); }; const handleImageClear = (type) => { // ← Prevenir cambios si está enviando if (isSubmitting) return; if (type === 'pregunta') { setFormData(prev => ({ ...prev, url: '', imagen: null })); } else { setFormData(prev => ({ ...prev, url_respuesta: '', imagen_respuesta: null })); } }; const handleAIGenerate = async (type) => { if (isAnyLoading) { setErrors(prev => ({ ...prev, [`ai_${type}`]: isSubmitting ? 'No se puede generar IA mientras se está creando la flashcard.' : 'Ya hay una generación de IA en proceso. Espera a que termine.' })); return; } setAiLoading(prev => ({ ...prev, [type]: true })); setErrors(prev => ({ ...prev, [`ai_${type}`]: null })); try { let prompt = ''; if (type === 'pregunta') { prompt = formData.respuesta ? `Genera una pregunta de flashcard para esta respuesta: ${formData.respuesta} pero que sea muy breve que es para la pregunta de una Flashcard y su dificultad debe ser a nivel de Medico Universitario.` : `Genera una pregunta de flashcard para una flashcard que sea muy breve que es para la pregunta de una Flashcard tiene que ser de caracter medico y su dificultad debe ser a nivel de Medico Universitario.`; } else { prompt = formData.pregunta ? `Genera una respuesta breve y clara para esta pregunta: ${formData.pregunta} pero que sea muy breve que es para la respuesta de una Flashcard y su dificultad debe ser a nivel de Medico Universitario.` : `Genera una respuesta educativa para una flashcard que sea muy breve que es para la respuesta de una Flashcard y su dificultad debe ser a nivel de Medico Universitario.`; } const result = await api.get('/api/flashcard/ai-generate', { params: { type: type, prompt: prompt, current_text: formData[type] } }); if (result.success) { const generatedText = result.data?.data?.generated_text || result.data?.generated_text; if (generatedText) { // ← Activar animación ANTES de cambiar el valor setAiAnimating(prev => ({ ...prev, [type]: true })); // ← Cambiar el valor directamente setFormData(prev => ({ ...prev, [type]: generatedText })); // ← Desactivar animación después de un tiempo setTimeout(() => { setAiAnimating(prev => ({ ...prev, [type]: false })); }, generatedText.length * 25); // 25ms por carácter } else { setErrors(prev => ({ ...prev, [`ai_${type}`]: 'No se pudo generar el texto' })); } } else { setErrors(prev => ({ ...prev, [`ai_${type}`]: result.error || 'Error al generar contenido con IA' })); } } catch (error) { setErrors(prev => ({ ...prev, [`ai_${type}`]: 'Error de conexión con el servicio de IA' })); } finally { setAiLoading(prev => ({ ...prev, [type]: false })); } }; const handleSubmit = async (e) => { e.preventDefault(); setErrors({}); setSuccessMessage(''); // ← Prevenir envío si hay cualquier loading activo if (isAnyLoading) { setErrors({ general: isAnyAiLoading ? 'Espera a que termine la generación de IA antes de enviar el formulario.' : 'Ya se está procesando la flashcard.' }); return; } // Validación básica del cliente const newErrors = {}; if (!formData.pregunta.trim()) { newErrors.pregunta = 'La pregunta es requerida'; } else if (formData.pregunta.trim().length <= 5) { newErrors.pregunta = 'La pregunta debe tener al menos 5 caracteres'; } if (!formData.respuesta.trim()) { newErrors.respuesta = 'La respuesta es requerida'; } else if (formData.respuesta.trim().length <= 2) { newErrors.respuesta = 'La respuesta debe tener al menos 2 caracteres'; } if (formData.url.trim() && formData.imagen) { newErrors.imagen = 'No puedes subir una imagen y una URL al mismo tiempo para la pregunta'; } if (formData.url_respuesta.trim() && formData.imagen_respuesta) { newErrors.imagen_respuesta = 'No puedes subir una imagen y una URL al mismo tiempo para la respuesta'; } if (Object.keys(newErrors).length > 0) { setErrors(newErrors); return; } setIsSubmitting(true); // ← Activar loading try { const submitData = new FormData(); submitData.append('pregunta', formData.pregunta.trim()); submitData.append('respuesta', formData.respuesta.trim()); submitData.append('url', formData.url.trim()); submitData.append('url_respuesta', formData.url_respuesta.trim()); if (formData.imagen) { submitData.append('imagen', formData.imagen); } if (formData.imagen_respuesta) { submitData.append('imagen_respuesta', formData.imagen_respuesta); } if (formData.selectedCategories.length > 0) { submitData.append('categorias', JSON.stringify(formData.selectedCategories)); } const result = await api.post('/api/flashcard', submitData, { headers: { 'Content-Type': 'multipart/form-data' } }); // En FlashcardForm.jsx, en el handleSubmit: if (result.success) { console.log('✅ Respuesta completa flashcard:', result); // ✅ EXTRAER LA FLASHCARD CORRECTAMENTE const newFlashcard = result.data.data || result.data.flashcard || result.data; console.log('✅ Flashcard extraída:', newFlashcard); setSuccessMessage('Flashcard creada exitosamente'); setFormData({ pregunta: '', respuesta: '', url: '', imagen: null, url_respuesta: '', imagen_respuesta: null, selectedCategories: [] }); if (onFlashcardCreated && newFlashcard && newFlashcard.id) { console.log('📤 Enviando flashcard al padre:', newFlashcard); onFlashcardCreated(newFlashcard); } else { console.error('❌ No se pudo extraer la flashcard válida:', newFlashcard); } } else { if (result.status === 422 && result.errors) { const serverErrors = {}; Object.keys(result.errors).forEach(field => { serverErrors[field] = Array.isArray(result.errors[field]) ? result.errors[field][0] : result.errors[field]; }); setErrors(serverErrors); } else { setErrors({ general: result.error || 'Error al crear la flashcard' }); } } } catch (error) { setErrors({ general: 'Error de conexión. Intenta nuevamente.' }); } finally { setIsSubmitting(false); // ← Desactivar loading } }; return ( <> {/* ← Loading fullscreen */} <FullScreenLoader show={isSubmitting} message="Creando flashcard..." subMessage="Por favor espera, estamos procesando tu flashcard" /> <div className="bg-white p-6 rounded container-askt"> <h1 className="text-2xl font-semibold mb-4 primary-color title-ask-container"> Crear Flashcard </h1> <hr /> <form onSubmit={handleSubmit} className="form-container-ask mt-4"> <Alert type="error" message={errors.general} /> <Alert type="success" message={successMessage} /> {/* ← Mostrar alerta solo para IA, no para submit */} {isAnyAiLoading && !isSubmitting && ( <Alert type="info" message="🤖 Generando contenido con IA... Por favor espera antes de usar otros botones." className="mb-4" /> )} {/* Pregunta */} <TextAreaField id="pregunta" label="Pregunta" value={formData.pregunta} onChange={(e) => handleChange('pregunta', e.target.value)} placeholder="Ingresa la pregunta" error={errors.pregunta} required showAIButton={true} onAIGenerate={() => handleAIGenerate('pregunta')} aiLoading={aiLoading.pregunta} aiDisabled={isAnyLoading && !aiLoading.pregunta} disabled={isSubmitting} animated={true} aiAnimating={aiAnimating.pregunta} /> {errors.ai_pregunta && ( <Alert type="error" message={errors.ai_pregunta} className="mb-4" /> )} {/* Respuesta */} <TextAreaField id="respuesta" label="Respuesta" value={formData.respuesta} onChange={(e) => handleChange('respuesta', e.target.value)} placeholder="Ingresa la respuesta" error={errors.respuesta} required showAIButton={true} onAIGenerate={() => handleAIGenerate('respuesta')} aiLoading={aiLoading.respuesta} aiDisabled={isAnyLoading && !aiLoading.respuesta} disabled={isSubmitting} animated={true} aiAnimating={aiAnimating.respuesta} /> {errors.ai_respuesta && ( <Alert type="error" message={errors.ai_respuesta} className="mb-4" /> )} {/* Imágenes en 2 columnas */} <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <ImageUploadField id="imagen_pregunta" label="Imagen de Pregunta" urlValue={formData.url} fileValue={formData.imagen} onUrlChange={(value) => handleImageUrlChange('url', value)} onFileChange={(file) => handleImageFileChange('imagen', file)} onClear={() => handleImageClear('pregunta')} error={errors.imagen || errors.url} disabled={isSubmitting} /> <ImageUploadField id="imagen_respuesta" label="Imagen de Respuesta" urlValue={formData.url_respuesta} fileValue={formData.imagen_respuesta} onUrlChange={(value) => handleImageUrlChange('url_respuesta', value)} onFileChange={(file) => handleImageFileChange('imagen_respuesta', file)} onClear={() => handleImageClear('respuesta')} error={errors.imagen_respuesta || errors.url_respuesta} disabled={isSubmitting} /> </div> {/* Selector de categorías */} <CategorySelector categories={categories} selectedCategories={formData.selectedCategories} onChange={(selected) => handleChange('selectedCategories', selected)} /> <Button type="submit" variant="primary" disabled={isAnyLoading} className="boton-success-m w-full md:w-auto" > {isSubmitting ? ( <> <svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 814 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> </svg> Creando... </> ) : isAnyAiLoading ? 'Esperando IA...' : 'Crear Flashcard'} </Button> </form> </div> </> ); }; export default FlashcardForm;