Files
GenExam-IA/frontend/src/pages/template/ImagesTab.jsx
T
Mireya Cueto Garrido 946f16a633 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.
2026-06-01 13:27:41 +02:00

190 lines
5.8 KiB
React

import { useState } from "react";
import { uploadImage, deleteImage } from "../../api/images";
import { useToast } from "../../context/ToastContext";
import FileDropzone from "../../components/FileDropzone";
import AuthImage from "../../components/AuthImage";
import StorageBar from "../../components/StorageBar";
import Button from "../../components/ui/Button";
import { EmptyState } from "../../components/ui/Misc";
import { ConfirmDialog } from "../../components/ui/Modal";
import Modal from "../../components/ui/Modal";
import { Field, Input } from "../../components/ui/Field";
import { IMAGE_ACCEPT } from "../../utils/constants";
import { formatBytes } from "../../utils/format";
export default function ImagesTab({
templateId,
images,
storage,
reloadImages,
reloadStorage,
}) {
const toast = useToast();
const [pendingFile, setPendingFile] = useState(null);
const [caption, setCaption] = useState("");
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [toDelete, setToDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
const handleSelected = (file) => {
setPendingFile(file);
setCaption("");
};
const handleUpload = async () => {
if (!pendingFile) return;
setUploading(true);
setProgress(0);
try {
await uploadImage(templateId, pendingFile, caption, setProgress);
await Promise.all([reloadImages(), reloadStorage()]);
toast.success("Imagen subida correctamente.");
setPendingFile(null);
setCaption("");
} catch (err) {
toast.error(err.message);
} finally {
setUploading(false);
setProgress(0);
}
};
const confirmDelete = async () => {
setDeleting(true);
try {
await deleteImage(templateId, toDelete.id);
await Promise.all([reloadImages(), reloadStorage()]);
toast.success("Imagen eliminada.");
setToDelete(null);
} catch (err) {
toast.error(err.message);
} finally {
setDeleting(false);
}
};
return (
<div className="grid" style={{ gridTemplateColumns: "1fr 320px" }}>
<div>
<div className="card mb">
<div className="card-body">
<FileDropzone
accept={IMAGE_ACCEPT}
icon="image"
hint="PNG, JPG, WEBP o GIF. Se mostrarán dentro del examen (no se les extrae texto)."
onFile={handleSelected}
/>
</div>
</div>
{images.length === 0 ? (
<div className="card">
<EmptyState
icon="image"
title="Sin imágenes"
message="Sube imágenes para crear preguntas visuales y enlazarlas en la pestaña Preguntas."
/>
</div>
) : (
<div className="grid grid-cards">
{images.map((img) => (
<div key={img.id} className="card card-pad">
<AuthImage imageId={img.id} alt={img.original_filename} />
<div style={{ marginTop: 10 }}>
<div className="list-item-title">{img.original_filename}</div>
<div className="text-sm text-faint">
{formatBytes(img.size_bytes)}
{img.caption ? ` · ${img.caption}` : ""}
</div>
</div>
<Button
variant="danger-ghost"
size="sm"
className="mt"
onClick={() => setToDelete(img)}
>
Eliminar
</Button>
</div>
))}
</div>
)}
</div>
<div>
<div className="card mb">
<div className="card-head">
<h3>Espacio</h3>
</div>
<div className="card-body">
<StorageBar storage={storage} />
</div>
</div>
<div className="card">
<div className="card-body text-sm text-soft">
<strong>Consejo</strong>
<p style={{ marginBottom: 0 }}>
Añade una descripción a cada imagen. La IA la usa para decidir a
qué pregunta corresponde cada imagen al generar el examen.
</p>
</div>
</div>
</div>
{/* Modal de caption antes de subir */}
<Modal
open={!!pendingFile}
onClose={() => !uploading && setPendingFile(null)}
title="Subir imagen"
footer={
<>
<Button
variant="ghost"
onClick={() => setPendingFile(null)}
disabled={uploading}
>
Cancelar
</Button>
<Button onClick={handleUpload} loading={uploading}>
Subir imagen
</Button>
</>
}
>
<p className="text-soft text-sm">
Archivo: <strong>{pendingFile?.name}</strong> (
{formatBytes(pendingFile?.size)})
</p>
<Field
label="Descripción / pie de imagen (opcional)"
hint="Ayuda a la IA a asociar la imagen con la pregunta correcta."
>
<Input
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="Ej. Diagrama del ciclo del agua"
maxLength={500}
/>
</Field>
{uploading && (
<div className="progress mt">
<div className="progress-bar" style={{ width: `${progress}%` }} />
</div>
)}
</Modal>
<ConfirmDialog
open={!!toDelete}
title="Eliminar imagen"
message={`¿Eliminar "${toDelete?.original_filename}"? Se desvinculará de cualquier pregunta que la use.`}
confirmLabel="Eliminar"
danger
loading={deleting}
onConfirm={confirmDelete}
onClose={() => setToDelete(null)}
/>
</div>
);
}