Files
openccb/web/studio/src/components/QuestionBank/QuestionBankEditor.tsx
T

515 lines
26 KiB
TypeScript

'use client';
import React, { useState } from 'react';
import { questionBankApi, QuestionBank, CreateQuestionBankPayload, QuestionBankType } from '@/lib/api';
import { X, Save, Sparkles, Volume2, Trash2, Upload } from 'lucide-react';
interface QuestionBankEditorProps {
question?: QuestionBank | null;
onSuccess?: () => void;
onCancel?: () => void;
}
export default function QuestionBankEditor({ question, onSuccess, onCancel }: QuestionBankEditorProps) {
const [formData, setFormData] = useState<CreateQuestionBankPayload>({
question_text: question?.question_text || '',
question_type: question?.question_type || 'multiple-choice',
options: question?.options || (question?.question_type === 'true-false' ? ['Verdadero', 'Falso'] : undefined),
correct_answer: question?.correct_answer,
explanation: question?.explanation || '',
points: question?.points || 1,
difficulty: question?.difficulty || 'medium',
tags: question?.tags || [],
media_url: question?.media_url,
media_type: question?.media_type,
skill_assessed: question?.skill_assessed,
});
const [audioFile, setAudioFile] = useState<File | null>(null);
const [audioPreview, setAudioPreview] = useState<string | null>(question?.audio_url || null);
const [uploadingAudio, setUploadingAudio] = useState(false);
const [newTag, setNewTag] = useState('');
const [saving, setSaving] = useState(false);
const [generatingAI, setGeneratingAI] = useState(false);
const normalizedOptions = Array.isArray(formData.options)
? (formData.options as string[])
: [];
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.question_text.trim()) {
alert('El texto de la pregunta es obligatorio');
return;
}
try {
setSaving(true);
let audioUrl = audioPreview;
// Upload audio file if provided
if (audioFile) {
setUploadingAudio(true);
const audioFormData = new FormData();
audioFormData.append('file', audioFile);
audioFormData.append('type', 'question_audio');
const uploadResponse = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/assets/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: audioFormData,
});
if (uploadResponse.ok) {
const uploadResult = await uploadResponse.json();
audioUrl = uploadResult.url || uploadResult.file_url;
} else {
console.warn('Audio upload failed, continuing without audio');
}
setUploadingAudio(false);
}
const payload = {
...formData,
audio_url: audioUrl || undefined,
audio_text: formData.question_text, // Suggestion for what the audio should say
};
if (question) {
await questionBankApi.update(question.id, payload);
} else {
await questionBankApi.create(payload);
}
onSuccess?.();
} catch (error) {
console.error('Failed to save question:', error);
alert('Error al guardar la pregunta');
} finally {
setSaving(false);
}
};
const handleAddTag = () => {
if (newTag.trim() && !formData.tags?.includes(newTag.trim())) {
setFormData({
...formData,
tags: [...(formData.tags || []), newTag.trim()],
});
setNewTag('');
}
};
const handleRemoveTag = (tagToRemove: string) => {
setFormData({
...formData,
tags: formData.tags?.filter(tag => tag !== tagToRemove) || [],
});
};
const handleAddOption = () => {
const currentOptions = normalizedOptions;
setFormData({
...formData,
options: [...currentOptions, `Opción ${currentOptions.length + 1}`],
});
};
const handleRemoveOption = (index: number) => {
const newOptions = normalizedOptions.filter((_, i) => i !== index);
setFormData({ ...formData, options: newOptions });
// Adjust correct answer if needed
if (typeof formData.correct_answer === 'number' && formData.correct_answer === index) {
setFormData({ ...formData, correct_answer: undefined });
} else if (typeof formData.correct_answer === 'number' && formData.correct_answer > index) {
setFormData({ ...formData, correct_answer: formData.correct_answer - 1 });
}
};
const handleGenerateWithAI = async () => {
if (!formData.question_text.trim()) {
alert('Ingresa una pregunta base o contexto para que la IA genere las opciones y explicación');
return;
}
// Define las 4 habilidades del inglés que deben cubrirse
const skills = ['reading', 'listening', 'speaking', 'writing'];
const randomSkill = skills[Math.floor(Math.random() * skills.length)];
const skillPrompts = {
reading: 'Focus on reading comprehension, vocabulary in context, or text analysis.',
listening: 'Focus on listening comprehension, audio-based understanding, or spoken dialogue interpretation.',
speaking: 'Focus on oral production, pronunciation, or conversational response.',
writing: 'Focus on written production, grammar in writing, or composition skills.'
};
try {
setGeneratingAI(true);
// AI generation con el nuevo endpoint de question bank
const token = localStorage.getItem('studio_token');
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/question-bank/ai-generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
question_text: formData.question_text,
difficulty: formData.difficulty,
skill: randomSkill,
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Error ${response.status}: ${errorText}`);
}
const data = await response.json();
if (data.options && data.correct_answer !== undefined) {
setFormData({
...formData,
options: data.options,
correct_answer: data.correct_answer,
explanation: data.explanation
? `${data.explanation}\n\n📊 Skill assessed: ${randomSkill.toUpperCase()}`
: `This question assesses ${randomSkill.toUpperCase()} skills.`,
tags: [...(formData.tags || []), randomSkill, 'ai-generated'],
});
alert(`IA generó las opciones y explicación enfocadas en la habilidad: ${randomSkill.toUpperCase()}`);
}
} catch (error) {
console.error('AI generation error:', error);
alert(`Error al generar con IA: ${error instanceof Error ? error.message : 'Verifica que Ollama esté configurado'}`);
} finally {
setGeneratingAI(false);
}
};
const isMultipleChoice = formData.question_type === 'multiple-choice' || formData.question_type === 'true-false';
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-3xl w-full my-8 max-h-[calc(100vh-4rem)] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800 z-10">
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
{question ? 'Editar Pregunta' : 'Nueva Pregunta'}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{question ? 'Modifica los detalles de la pregunta' : 'Crea una nueva pregunta para el banco'}
</p>
</div>
<button
onClick={onCancel}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Question Type & Difficulty */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tipo de Pregunta *
</label>
<select
value={formData.question_type}
onChange={(e) => setFormData({
...formData,
question_type: e.target.value as QuestionBankType,
options: e.target.value === 'true-false' ? ['Verdadero', 'Falso'] : formData.options,
})}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="multiple-choice">Opción Múltiple</option>
<option value="true-false">Verdadero/Falso</option>
<option value="short-answer">Respuesta Corta</option>
<option value="essay">Ensayo</option>
<option value="matching">Emparejamiento</option>
<option value="ordering">Ordenar</option>
<option value="fill-in-the-blanks">Completar Espacios</option>
<option value="audio-response">Respuesta de Audio</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Dificultad
</label>
<select
value={formData.difficulty}
onChange={(e) => setFormData({ ...formData, difficulty: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="easy">Fácil</option>
<option value="medium">Media</option>
<option value="hard">Difícil</option>
</select>
</div>
</div>
{/* Question Text */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Pregunta *
</label>
<textarea
value={formData.question_text}
onChange={(e) => setFormData({ ...formData, question_text: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Escribe el enunciado de la pregunta..."
required
/>
</div>
{/* Options for Multiple Choice */}
{isMultipleChoice && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Opciones (marca la correcta)
</label>
<button
type="button"
onClick={handleAddOption}
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<Upload className="w-3 h-3" />
Agregar opción
</button>
</div>
{normalizedOptions.map((option: string, idx: number) => (
<div key={idx} className="flex items-center gap-2">
<input
type="radio"
name="correct_answer"
checked={formData.correct_answer === idx}
onChange={() => setFormData({ ...formData, correct_answer: idx })}
className="w-4 h-4 text-blue-600"
/>
<input
type="text"
value={option}
onChange={(e) => {
const newOptions = [...normalizedOptions];
newOptions[idx] = e.target.value;
setFormData({ ...formData, options: newOptions });
}}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white"
placeholder={`Opción ${idx + 1}`}
/>
<button
type="button"
onClick={() => handleRemoveOption(idx)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{/* AI Generation Button */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleGenerateWithAI}
disabled={generatingAI || !formData.question_text.trim()}
className="flex items-center gap-2 px-3 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
<Sparkles className="w-4 h-4" />
{generatingAI ? 'Generando...' : 'Generar con IA'}
</button>
<span className="text-xs text-gray-500 dark:text-gray-400">
Genera opciones y explicación automáticamente
</span>
</div>
{/* Explanation */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1 flex items-center gap-2">
Explicación / Feedback
<Sparkles className="w-3 h-3 text-purple-600" />
</label>
<textarea
value={formData.explanation}
onChange={(e) => setFormData({ ...formData, explanation: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:text-white"
placeholder="Explicación que se mostrará al estudiante después de responder..."
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Esta explicación ayuda al estudiante a entender por qué su respuesta fue correcta o incorrecta.
</p>
</div>
{/* Audio Upload Section */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
<Volume2 className="w-4 h-4" />
Audio de la Pregunta (Opcional)
</label>
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4 text-center">
{audioPreview ? (
<div className="space-y-3">
<audio controls src={audioPreview} className="w-full" />
<button
type="button"
onClick={() => {
setAudioPreview(null);
setAudioFile(null);
}}
className="text-sm text-red-600 hover:text-red-700 flex items-center gap-1 mx-auto"
>
<Trash2 className="w-3 h-3" />
Eliminar audio
</button>
</div>
) : (
<div>
<input
type="file"
accept="audio/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setAudioFile(file);
setAudioPreview(URL.createObjectURL(file));
}
}}
className="hidden"
id="audio-upload"
/>
<label
htmlFor="audio-upload"
className="cursor-pointer flex flex-col items-center gap-2"
>
<Volume2 className="w-8 h-8 text-gray-400" />
<div>
<span className="text-sm font-medium text-blue-600 hover:text-blue-700">
Subir archivo de audio
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{' '}o arrastrar aquí
</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500">
MP3, WAV, OGG - Máx 10MB
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-2">
💡 Sugerencia: Graba la pregunta con tu celular y súbelala aquí
</p>
</label>
</div>
)}
</div>
{audioFile && !audioPreview && (
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
Subiendo: {audioFile.name} ({(audioFile.size / 1024 / 1024).toFixed(2)} MB)
</div>
)}
</div>
{/* Points & Tags */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Puntos
</label>
<input
type="number"
value={formData.points}
onChange={(e) => setFormData({ ...formData, points: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
min="1"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Etiquetas
</label>
<div className="flex gap-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddTag())}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Agregar etiqueta..."
/>
<button
type="button"
onClick={handleAddTag}
className="px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<Upload className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Tags Display */}
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag, idx) => (
<span
key={idx}
className="px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded-full text-sm flex items-center gap-1"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:text-blue-600"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-6 border-t border-gray-200 dark:border-gray-700 sticky bottom-0 bg-white dark:bg-gray-800 -mx-6 px-6 py-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? 'Guardando...' : (question ? 'Actualizar' : 'Guardar Pregunta')}
</button>
</div>
</form>
</div>
</div>
);
}