Add React frontend and Sinbad2IA LLM integration.

Introduce a full Vite/React UI for exams, auth, materials, images, generation, and export.
Adapt backend for Sinbad2IA chat API, bcrypt passwords, CORS on port 5173, and schema migrations.
This commit is contained in:
Mireya Cueto Garrido
2026-06-01 13:27:41 +02:00
parent 7bc27da33a
commit 946f16a633
66 changed files with 6769 additions and 48 deletions
+357
View File
@@ -0,0 +1,357 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createTemplate } from "../api/templates";
import { useToast } from "../context/ToastContext";
import { Field, Input, Select, Checkbox } from "../components/ui/Field";
import Button from "../components/ui/Button";
import { Badge } from "../components/ui/Misc";
import { QUESTION_TYPES, DIFFICULTIES } from "../utils/constants";
import { totalQuestionsFromProfile } from "../utils/format";
const emptyType = () => ({
type: "multichoice",
count: 5,
options_count: 4,
multiple_correct: false,
score: 1,
penalty: 0,
});
export default function CreateTemplatePage() {
const navigate = useNavigate();
const toast = useToast();
const [form, setForm] = useState({
title: "",
subject: "",
educational_level: "",
language: "es",
});
const [types, setTypes] = useState([emptyType()]);
const [settings, setSettings] = useState({
shuffle_questions: true,
shuffle_answers: true,
include_feedback: true,
});
const [difficulty, setDifficulty] = useState({
easy: 2,
medium: 3,
hard: 0,
very_hard: 0,
});
const [errors, setErrors] = useState({});
const [saving, setSaving] = useState(false);
const totalDifficulty = useMemo(
() => totalQuestionsFromProfile(difficulty),
[difficulty]
);
const onField = (e) =>
setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
const updateType = (idx, patch) =>
setTypes((prev) => prev.map((t, i) => (i === idx ? { ...t, ...patch } : t)));
const addType = () => setTypes((prev) => [...prev, emptyType()]);
const removeType = (idx) =>
setTypes((prev) => prev.filter((_, i) => i !== idx));
const validate = () => {
const e = {};
if (form.title.trim().length < 3) e.title = "Mínimo 3 caracteres.";
if (form.subject.trim().length < 2) e.subject = "Mínimo 2 caracteres.";
if (form.educational_level.trim().length < 2)
e.educational_level = "Mínimo 2 caracteres.";
if (types.length === 0) e.types = "Añade al menos un tipo de pregunta.";
if (totalDifficulty <= 0)
e.difficulty = "Reparte al menos una pregunta entre las dificultades.";
setErrors(e);
return Object.keys(e).length === 0;
};
const submit = async (e) => {
e.preventDefault();
if (!validate()) {
toast.error("Revisa los campos marcados.");
return;
}
const payload = {
...form,
title: form.title.trim(),
subject: form.subject.trim(),
educational_level: form.educational_level.trim(),
settings: {
question_types: types.map((t) => ({
type: t.type,
count: Number(t.count),
options_count:
t.type === "multichoice" ? Number(t.options_count) : null,
multiple_correct:
t.type === "multichoice" ? t.multiple_correct : false,
score: Number(t.score),
penalty: Number(t.penalty),
})),
...settings,
},
difficulty_profile: {
easy: Number(difficulty.easy) || 0,
medium: Number(difficulty.medium) || 0,
hard: Number(difficulty.hard) || 0,
very_hard: Number(difficulty.very_hard) || 0,
},
};
setSaving(true);
try {
const created = await createTemplate(payload);
toast.success("Plantilla creada correctamente.");
navigate(`/plantillas/${created.id}`);
} catch (err) {
toast.error(err.message);
if (err.details) {
console.warn("Detalles de validación:", err.details);
}
} finally {
setSaving(false);
}
};
return (
<div className="page page-narrow">
<div className="page-header">
<div>
<h1>Nuevo examen</h1>
<p>Define la estructura. Después podrás subir material y generar preguntas.</p>
</div>
</div>
<form onSubmit={submit} noValidate>
{/* Datos generales */}
<div className="card mb">
<div className="card-head">
<h3>1 · Información general</h3>
</div>
<div className="card-body">
<Field label="Título del examen" error={errors.title}>
<Input
name="title"
value={form.title}
onChange={onField}
placeholder="Ej. Examen Tema 3 — Sistemas Operativos"
error={errors.title}
/>
</Field>
<div className="row">
<Field label="Asignatura" error={errors.subject}>
<Input
name="subject"
value={form.subject}
onChange={onField}
placeholder="Ej. Sistemas Operativos"
error={errors.subject}
/>
</Field>
<Field label="Nivel educativo" error={errors.educational_level}>
<Input
name="educational_level"
value={form.educational_level}
onChange={onField}
placeholder="Ej. Ciclo Superior DAM"
error={errors.educational_level}
/>
</Field>
</div>
<Field label="Idioma">
<Select name="language" value={form.language} onChange={onField}>
<option value="es">Español</option>
<option value="en">Inglés</option>
<option value="fr">Francés</option>
<option value="de">Alemán</option>
<option value="it">Italiano</option>
<option value="pt">Portugués</option>
</Select>
</Field>
</div>
</div>
{/* Tipos de pregunta */}
<div className="card mb">
<div className="card-head">
<h3>2 · Tipos de pregunta</h3>
<span style={{ flex: 1 }} />
<Button type="button" variant="subtle" size="sm" onClick={addType}>
+ Añadir tipo
</Button>
</div>
<div className="card-body">
{errors.types && <div className="field-error mb">{errors.types}</div>}
{types.map((t, idx) => (
<div
key={idx}
className="card"
style={{ padding: 16, marginBottom: 14, background: "var(--c-surface-2)" }}
>
<div className="flex justify-between items-center mb">
<strong>Bloque {idx + 1}</strong>
{types.length > 1 && (
<Button
type="button"
variant="danger-ghost"
size="sm"
onClick={() => removeType(idx)}
>
Eliminar
</Button>
)}
</div>
<div className="row">
<Field label="Tipo">
<Select
value={t.type}
onChange={(e) => updateType(idx, { type: e.target.value })}
>
{QUESTION_TYPES.map((q) => (
<option key={q.value} value={q.value}>
{q.label}
</option>
))}
</Select>
</Field>
<Field label="Nº preguntas">
<Input
type="number"
min={1}
max={200}
value={t.count}
onChange={(e) => updateType(idx, { count: e.target.value })}
/>
</Field>
</div>
<div className="row">
<Field label="Puntuación">
<Input
type="number"
min={0}
step={0.5}
value={t.score}
onChange={(e) => updateType(idx, { score: e.target.value })}
/>
</Field>
<Field label="Penalización">
<Input
type="number"
min={0}
step={0.25}
value={t.penalty}
onChange={(e) =>
updateType(idx, { penalty: e.target.value })
}
/>
</Field>
</div>
{t.type === "multichoice" && (
<div className="row items-center">
<Field label="Nº de opciones">
<Input
type="number"
min={2}
max={8}
value={t.options_count}
onChange={(e) =>
updateType(idx, { options_count: e.target.value })
}
/>
</Field>
<div style={{ paddingTop: 26 }}>
<Checkbox
label="Permitir varias respuestas correctas"
checked={t.multiple_correct}
onChange={(e) =>
updateType(idx, { multiple_correct: e.target.checked })
}
/>
</div>
</div>
)}
</div>
))}
</div>
</div>
{/* Perfil de dificultad */}
<div className="card mb">
<div className="card-head">
<h3>3 · Reparto por dificultad</h3>
<span style={{ flex: 1 }} />
<Badge variant={totalDifficulty > 0 ? "primary" : "danger"}>
{totalDifficulty} preguntas
</Badge>
</div>
<div className="card-body">
{errors.difficulty && (
<div className="field-error mb">{errors.difficulty}</div>
)}
<div className="grid grid-2">
{DIFFICULTIES.map((d) => (
<Field key={d.value} label={d.label}>
<Input
type="number"
min={0}
value={difficulty[d.value]}
onChange={(e) =>
setDifficulty((p) => ({ ...p, [d.value]: e.target.value }))
}
/>
</Field>
))}
</div>
<p className="field-hint">
Indica cuántas preguntas quieres de cada nivel. La IA intentará
respetar este reparto.
</p>
</div>
</div>
{/* Opciones */}
<div className="card mb">
<div className="card-head">
<h3>4 · Opciones del examen</h3>
</div>
<div className="card-body flex" style={{ flexDirection: "column", gap: 14 }}>
<Checkbox
label="Barajar el orden de las preguntas"
checked={settings.shuffle_questions}
onChange={(e) =>
setSettings((s) => ({ ...s, shuffle_questions: e.target.checked }))
}
/>
<Checkbox
label="Barajar el orden de las respuestas"
checked={settings.shuffle_answers}
onChange={(e) =>
setSettings((s) => ({ ...s, shuffle_answers: e.target.checked }))
}
/>
<Checkbox
label="Incluir retroalimentación (feedback) en las preguntas"
checked={settings.include_feedback}
onChange={(e) =>
setSettings((s) => ({ ...s, include_feedback: e.target.checked }))
}
/>
</div>
</div>
<div className="flex gap justify-between">
<Button type="button" variant="ghost" onClick={() => navigate("/")}>
Cancelar
</Button>
<Button type="submit" size="lg" loading={saving}>
Crear examen
</Button>
</div>
</form>
</div>
);
}