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
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.env
.env.local
*.log
.git
+6
View File
@@ -0,0 +1,6 @@
# URL base del backend (accesible desde el navegador)
VITE_API_URL=http://localhost:8000
# (Opcional) Client ID de Google para "Iniciar sesión con Google".
# Debe coincidir con GOOGLE_CLIENT_ID del backend.
VITE_GOOGLE_CLIENT_ID=
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.env
.env.local
*.log
.DS_Store
+20
View File
@@ -0,0 +1,20 @@
# --- Build stage ---
FROM node:20-alpine AS build
WORKDIR /app
ARG VITE_API_URL=http://localhost:8000
ARG VITE_GOOGLE_CLIENT_ID=
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# --- Serve stage ---
FROM nginx:1.27-alpine AS serve
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+71
View File
@@ -0,0 +1,71 @@
# GenExámenes IA — Frontend
Interfaz web en **React + Vite** para el generador de exámenes con IA. Consume
la API del backend (FastAPI) y cubre todo el flujo: autenticación, plantillas,
material de contexto, imágenes, generación con IA, gestión de preguntas y
exportación a Moodle.
## Stack
- **React 18** + **React Router 6**
- **Vite 5** (build y dev server)
- **Axios** con interceptores para el token JWT y el manejo unificado de errores HTTP
## Estructura
```
src/
api/ Cliente axios y un módulo por recurso (auth, templates, materials, images, ...)
components/ Componentes reutilizables (UI, layout, AuthImage, QuestionCard, ...)
context/ AuthContext (sesión) y ToastContext (notificaciones)
hooks/ useGoogleSignIn (login con Google opcional)
pages/ Páginas y pestañas del detalle de plantilla
utils/ Constantes y formateadores
```
## Desarrollo local
Requisitos: Node 20+ y el backend corriendo en `http://localhost:8000`.
```bash
cd frontend
cp .env.example .env # ajusta VITE_API_URL si es necesario
npm install
npm run dev # http://localhost:5173
```
## Variables de entorno
| Variable | Descripción |
| ----------------------- | -------------------------------------------------------- |
| `VITE_API_URL` | URL base del backend (por defecto `http://localhost:8000`). |
| `VITE_GOOGLE_CLIENT_ID` | (Opcional) Client ID de Google. Si está vacío, se oculta el botón de Google. |
> Las variables `VITE_*` se incrustan en el build, por lo que apuntan al backend
> tal y como lo verá el navegador del usuario.
## Build de producción
```bash
npm run build # genera dist/
npm run preview # sirve el build localmente
```
## Docker
El `docker-compose.yml` de la raíz construye el frontend con un build multi-stage
(Node → Nginx) y lo publica en `http://localhost:5173`:
```bash
docker compose up --build
```
Las variables `VITE_API_URL` y `VITE_GOOGLE_CLIENT_ID` pueden pasarse como
variables de entorno al ejecutar `docker compose`.
## Manejo de errores
Todas las respuestas de error del backend siguen el formato
`{ "error": { "code", "message", "details" } }`. El interceptor de Axios las
normaliza a una `ApiError` y la UI las muestra mediante *toasts*. Un `401`
global cierra la sesión automáticamente.
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="GenExámenes IA — Generador de exámenes con IA y exportación a Moodle." />
<title>GenExámenes IA</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA: cualquier ruta desconocida sirve index.html (React Router).
location / {
try_files $uri $uri/ /index.html;
}
# Cache de assets con hash.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
+2029
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "genexamenes-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
"vite": "^5.4.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#6366f1"/>
<path d="M9 8h11l3 3v13a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z" fill="#fff"/>
<path d="M19 8v3a1 1 0 0 0 1 1h3" fill="#c7d2fe"/>
<path d="M11 16h10M11 19.5h10M11 23h6" stroke="#6366f1" stroke-width="1.6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

+33
View File
@@ -0,0 +1,33 @@
import { Navigate, Route, Routes } from "react-router-dom";
import Layout from "./components/layout/Layout";
import ProtectedRoute from "./components/layout/ProtectedRoute";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
import DashboardPage from "./pages/DashboardPage";
import CreateTemplatePage from "./pages/CreateTemplatePage";
import TemplateDetailPage from "./pages/TemplateDetailPage";
import NotFoundPage from "./pages/NotFoundPage";
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/registro" element={<RegisterPage />} />
<Route
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route path="/" element={<DashboardPage />} />
<Route path="/plantillas/nueva" element={<CreateTemplatePage />} />
<Route path="/plantillas/:templateId" element={<TemplateDetailPage />} />
</Route>
<Route path="/404" element={<NotFoundPage />} />
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { api } from "./client";
export async function register({ email, password, full_name }) {
const { data } = await api.post("/auth/register", {
email,
password,
full_name: full_name || null,
});
return data;
}
export async function login({ email, password }) {
const { data } = await api.post("/auth/login", { email, password });
return data; // { access_token, token_type }
}
export async function loginWithGoogle(idToken) {
const { data } = await api.post("/auth/google", { id_token: idToken });
return data;
}
export async function getMe() {
const { data } = await api.get("/auth/me");
return data; // { id, email, full_name, created_at }
}
+100
View File
@@ -0,0 +1,100 @@
import axios from "axios";
export const API_URL =
import.meta.env.VITE_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
const TOKEN_KEY = "genex_token";
export function getToken() {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token) {
if (token) localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export const api = axios.create({
baseURL: API_URL,
});
api.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
/**
* Normaliza cualquier error de axios al formato de la API:
* { code, message, status, details }.
*/
export class ApiError extends Error {
constructor({ message, code, status, details }) {
super(message);
this.name = "ApiError";
this.code = code;
this.status = status;
this.details = details;
}
}
// Eventos para que la app reaccione a un 401 global (sesión caducada).
const listeners = new Set();
export function onUnauthorized(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
}
api.interceptors.response.use(
(res) => res,
(error) => {
if (!error.response) {
return Promise.reject(
new ApiError({
message:
"No se pudo conectar con el servidor. Comprueba que el backend está en marcha.",
code: "network_error",
status: 0,
})
);
}
const { status, data } = error.response;
const apiErr = data?.error || {};
if (status === 401) {
listeners.forEach((fn) => fn());
}
return Promise.reject(
new ApiError({
message:
apiErr.message ||
friendlyStatus(status) ||
"Se ha producido un error inesperado.",
code: apiErr.code || `http_${status}`,
status,
details: apiErr.details || null,
})
);
}
);
function friendlyStatus(status) {
const map = {
400: "Solicitud incorrecta.",
401: "Sesión no válida o caducada.",
403: "No tienes acceso a este recurso.",
404: "Recurso no encontrado.",
409: "Conflicto con el estado actual.",
413: "El archivo o la petición es demasiado grande.",
422: "Los datos enviados no son válidos.",
429: "Demasiadas peticiones, inténtalo más tarde.",
500: "Error interno del servidor.",
503: "Servicio no disponible.",
};
return map[status];
}
+30
View File
@@ -0,0 +1,30 @@
import { api } from "./client";
const FORMATS = {
xml: { path: "xml", ext: "xml", responseType: "text" },
txt: { path: "txt", ext: "txt", responseType: "text" },
json: { path: "json", ext: "json", responseType: "text" },
};
export async function fetchExport(templateId, format) {
const cfg = FORMATS[format];
const res = await api.get(`/exam/export/${cfg.path}/${templateId}`, {
responseType: "text",
transformResponse: (d) => d,
});
return res.data;
}
export function downloadString(content, filename, mime) {
const blob = new Blob([content], { type: mime || "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export const EXPORT_FORMATS = FORMATS;
+27
View File
@@ -0,0 +1,27 @@
import { api } from "./client";
export async function buildPrompt(templateId, { topic_prompt, material_ids }) {
const { data } = await api.post(`/exam/prompts/${templateId}`, {
topic_prompt,
material_ids: material_ids?.length ? material_ids : null,
});
return data; // { template_id, prompt, expected_format }
}
export async function generateExam({ template_id, topic_prompt, material_ids }) {
const { data } = await api.post("/exam/generate", {
template_id,
topic_prompt,
material_ids: material_ids?.length ? material_ids : null,
});
return data; // { questions: [...] }
}
export async function parseOutput({ template_id, raw_output, input_format }) {
const { data } = await api.post("/exam/parse", {
template_id,
raw_output,
input_format,
});
return data; // { questions: [...] }
}
+41
View File
@@ -0,0 +1,41 @@
import { api, API_URL, getToken } from "./client";
export async function uploadImage(templateId, file, caption, onProgress) {
const form = new FormData();
form.append("file", file);
if (caption) form.append("caption", caption);
const { data } = await api.post(
`/exam/templates/${templateId}/images`,
form,
{
onUploadProgress: (e) => {
if (onProgress && e.total) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
},
}
);
return data; // { image, message }
}
export async function listImages(templateId) {
const { data } = await api.get(`/exam/templates/${templateId}/images`);
return data;
}
export async function deleteImage(templateId, imageId) {
await api.delete(`/exam/templates/${templateId}/images/${imageId}`);
}
/**
* El contenido de imagen requiere Authorization, así que lo descargamos
* como blob y devolvemos una object URL para usar en <img src>.
*/
export async function fetchImageBlobUrl(imageId) {
const res = await api.get(`/exam/images/${imageId}/content`, {
responseType: "blob",
});
return URL.createObjectURL(res.data);
}
export { API_URL, getToken };
+27
View File
@@ -0,0 +1,27 @@
import { api } from "./client";
export async function uploadMaterial(templateId, file, onProgress) {
const form = new FormData();
form.append("file", file);
const { data } = await api.post(
`/exam/templates/${templateId}/materials`,
form,
{
onUploadProgress: (e) => {
if (onProgress && e.total) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
},
}
);
return data; // { material, message }
}
export async function listMaterials(templateId) {
const { data } = await api.get(`/exam/templates/${templateId}/materials`);
return data;
}
export async function deleteMaterial(templateId, materialId) {
await api.delete(`/exam/templates/${templateId}/materials/${materialId}`);
}
+8
View File
@@ -0,0 +1,8 @@
import { api } from "./client";
export async function attachImageToQuestion(questionId, imageId) {
const { data } = await api.patch(`/exam/questions/${questionId}/image`, {
image_id: imageId,
});
return data; // QuestionRead
}
+26
View File
@@ -0,0 +1,26 @@
import { api } from "./client";
export async function createTemplate(payload) {
const { data } = await api.post("/exam/templates", payload);
return data;
}
export async function listTemplates() {
const { data } = await api.get("/exam/templates");
return data;
}
export async function getTemplate(templateId) {
const { data } = await api.get(`/exam/templates/${templateId}`);
return data;
}
export async function listQuestions(templateId) {
const { data } = await api.get(`/exam/templates/${templateId}/questions`);
return data;
}
export async function getTemplateStorage(templateId) {
const { data } = await api.get(`/exam/templates/${templateId}/storage`);
return data;
}
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
import { fetchImageBlobUrl } from "../api/images";
import Spinner from "./ui/Spinner";
/**
* El endpoint de contenido de imagen requiere Authorization, por lo que
* no se puede usar directamente en src. Descargamos el blob con el token.
*/
export default function AuthImage({ imageId, alt, className = "thumb" }) {
const [url, setUrl] = useState(null);
const [error, setError] = useState(false);
useEffect(() => {
let active = true;
let objectUrl;
setUrl(null);
setError(false);
fetchImageBlobUrl(imageId)
.then((u) => {
objectUrl = u;
if (active) setUrl(u);
else URL.revokeObjectURL(u);
})
.catch(() => active && setError(true));
return () => {
active = false;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [imageId]);
if (error) {
return (
<div className={className} style={{ display: "grid", placeItems: "center" }}>
<span className="text-faint text-sm">No disponible</span>
</div>
);
}
if (!url) {
return (
<div className={className} style={{ display: "grid", placeItems: "center" }}>
<Spinner />
</div>
);
}
return <img src={url} alt={alt || "Imagen"} className={className} />;
}
+54
View File
@@ -0,0 +1,54 @@
import Icon from "./ui/Icon";
export default function AuthLayout({ children }) {
return (
<div className="auth-wrap">
<aside className="auth-aside">
<div className="brand" style={{ color: "#fff" }}>
<span className="brand-logo" style={{ background: "rgba(255,255,255,.18)" }}>
<Icon name="document" size={18} />
</span>
GenExámenes IA
</div>
<div>
<h2>Crea exámenes con IA y expórtalos a Moodle en minutos.</h2>
<p style={{ marginBottom: 32 }}>
Define la plantilla, sube tu material de estudio y deja que la IA
redacte las preguntas por ti.
</p>
<Feature icon="cpu" title="Generación con IA">
Preguntas tipo test, V/F, respuesta corta y emparejamiento.
</Feature>
<Feature icon="book" title="Material de contexto">
Sube PDF, DOCX, TXT o imágenes y la IA usará su contenido.
</Feature>
<Feature icon="moodle" title="Exportación Moodle">
Descarga el examen en XML compatible con Moodle, TXT o JSON.
</Feature>
</div>
<p className="text-sm" style={{ opacity: 0.7, margin: 0 }}>
© {new Date().getFullYear()} GenExámenes IA
</p>
</aside>
<main className="auth-main">
<div className="auth-card">{children}</div>
</main>
</div>
);
}
function Feature({ icon, title, children }) {
return (
<div className="auth-feature">
<div className="auth-feature-icon">
<Icon name={icon} size={18} />
</div>
<div>
<strong>{title}</strong>
<div style={{ color: "rgba(255,255,255,.78)", fontSize: 13.5 }}>
{children}
</div>
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { useRef, useState } from "react";
import Icon from "./ui/Icon";
export default function FileDropzone({ accept, onFile, hint, icon = "paperclip" }) {
const inputRef = useRef(null);
const [drag, setDrag] = useState(false);
const handleFiles = (files) => {
if (files && files.length) onFile(files[0]);
};
return (
<div
className="card"
style={{
borderStyle: "dashed",
borderColor: drag ? "var(--c-primary)" : "var(--c-border-strong)",
background: drag ? "var(--c-primary-soft)" : "var(--c-surface)",
padding: "30px 22px",
textAlign: "center",
cursor: "pointer",
transition: "all 0.15s",
}}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setDrag(true);
}}
onDragLeave={() => setDrag(false)}
onDrop={(e) => {
e.preventDefault();
setDrag(false);
handleFiles(e.dataTransfer.files);
}}
>
<div className="icon-wrap icon-box icon-box-lg" style={{ margin: "0 auto 12px" }}>
<Icon name={icon} size={26} className="icon-primary" />
</div>
<div style={{ fontWeight: 600 }}>
Arrastra un archivo o haz clic para seleccionar
</div>
{hint && <div className="field-hint">{hint}</div>}
<input
ref={inputRef}
type="file"
accept={accept}
style={{ display: "none" }}
onChange={(e) => {
handleFiles(e.target.files);
e.target.value = "";
}}
/>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import AuthImage from "./AuthImage";
import { Badge } from "./ui/Misc";
import Icon from "./ui/Icon";
import {
QUESTION_TYPE_LABEL,
DIFFICULTY_LABEL,
} from "../utils/constants";
export default function QuestionCard({ question, index, footer }) {
const diff = DIFFICULTY_LABEL[question.difficulty];
return (
<div className="card card-pad mb">
<div className="flex justify-between items-center mb wrap gap-sm">
<div className="flex gap-sm items-center wrap">
<Badge variant="primary">#{index}</Badge>
<Badge>{QUESTION_TYPE_LABEL[question.question_type] || question.question_type}</Badge>
{diff && <Badge variant={diff.badge.replace("badge-", "")}>{diff.label}</Badge>}
<span className="text-sm text-faint">
{question.score} pt{question.penalty ? ` · -${question.penalty}` : ""}
</span>
</div>
{question.image_id && (
<Badge variant="info">
<Icon name="image" size={12} className="icon-inline" />
Con imagen
</Badge>
)}
</div>
<p style={{ fontWeight: 600, marginTop: 0 }}>{question.statement}</p>
{question.image_id && (
<div style={{ maxWidth: 320, margin: "10px 0" }}>
<AuthImage imageId={question.image_id} alt="Imagen de la pregunta" />
</div>
)}
{question.question_type === "matching" ? (
<div className="grid grid-2">
{(question.matching_pairs || []).map((p, i) => (
<div key={i} className="text-sm" style={{ display: "flex", gap: 8 }}>
<span>{p.prompt}</span>
<span className="text-faint"></span>
<strong>{p.answer}</strong>
</div>
))}
</div>
) : (
<div className="flex" style={{ flexDirection: "column", gap: 6 }}>
{(question.correct_answers || []).map((a, i) => (
<div key={`c${i}`} className="text-sm icon-wrap" style={{ color: "var(--c-success)" }}>
<Icon name="check" size={14} className="icon-inline icon-success" />
{a}
</div>
))}
{(question.wrong_answers || []).map((a, i) => (
<div key={`w${i}`} className="text-sm text-soft icon-wrap">
<Icon name="x" size={14} className="icon-inline icon-muted" />
{a}
</div>
))}
</div>
)}
{footer && <div className="mt">{footer}</div>}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { formatBytes } from "../utils/format";
import Icon from "./ui/Icon";
export default function StorageBar({ storage }) {
if (!storage) return null;
const pct = storage.limit_bytes
? Math.min(100, Math.round((storage.used_bytes / storage.limit_bytes) * 100))
: 0;
const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "";
return (
<div>
<div className="flex justify-between text-sm mb" style={{ marginBottom: 6 }}>
<span className="text-soft">
Almacenamiento del examen ({formatBytes(storage.used_bytes)} /{" "}
{formatBytes(storage.limit_bytes)})
</span>
<strong>{pct}%</strong>
</div>
<div className="progress">
<div className={`progress-bar ${level}`} style={{ width: `${pct}%` }} />
</div>
<div className="flex gap text-sm text-faint" style={{ marginTop: 8 }}>
<span className="icon-wrap">
<Icon name="book" size={14} className="icon-inline" />
Materiales: {formatBytes(storage.materials_bytes)}
</span>
<span className="icon-wrap">
<Icon name="image" size={14} className="icon-inline" />
Imágenes: {formatBytes(storage.images_bytes)}
</span>
<span>Disponible: {formatBytes(storage.remaining_bytes)}</span>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { Outlet } from "react-router-dom";
import Navbar from "./Navbar";
export default function Layout() {
return (
<div className="app-shell">
<Navbar />
<Outlet />
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useState } from "react";
import { Link, NavLink, useNavigate } from "react-router-dom";
import { useAuth } from "../../context/AuthContext";
import { initials } from "../../utils/format";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import Icon from "../ui/Icon";
export default function Navbar() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [confirmOut, setConfirmOut] = useState(false);
const doLogout = () => {
logout();
navigate("/login");
};
return (
<header className="navbar">
<div className="navbar-inner">
<Link to="/" className="brand">
<span className="brand-logo">
<Icon name="document" size={18} />
</span>
GenExámenes IA
</Link>
<nav className="nav-links">
<NavLink to="/" end className="nav-link">
Mis exámenes
</NavLink>
<NavLink to="/plantillas/nueva" className="nav-link">
Crear examen
</NavLink>
</nav>
<span className="nav-spacer" />
<div className="nav-user">
<div className="avatar" title={user?.email}>
{initials(user?.full_name || user?.email)}
</div>
<Button variant="ghost" size="sm" onClick={() => setConfirmOut(true)}>
Salir
</Button>
</div>
</div>
<Modal
open={confirmOut}
onClose={() => setConfirmOut(false)}
title="Cerrar sesión"
footer={
<>
<Button variant="ghost" onClick={() => setConfirmOut(false)}>
Cancelar
</Button>
<Button variant="danger" onClick={doLogout}>
Cerrar sesión
</Button>
</>
}
>
<p className="text-soft" style={{ margin: 0 }}>
¿Seguro que quieres cerrar la sesión de <strong>{user?.email}</strong>?
</p>
</Modal>
</header>
);
}
@@ -0,0 +1,16 @@
import { Navigate, useLocation } from "react-router-dom";
import { useAuth } from "../../context/AuthContext";
import { SpinnerCenter } from "../ui/Spinner";
export default function ProtectedRoute({ children }) {
const { isAuthenticated, loading } = useAuth();
const location = useLocation();
if (loading) return <SpinnerCenter label="Cargando tu sesión…" />;
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
+30
View File
@@ -0,0 +1,30 @@
import Spinner from "./Spinner";
export default function Button({
variant = "primary",
size,
block,
loading,
disabled,
children,
className = "",
...props
}) {
const classes = [
"btn",
`btn-${variant}`,
size === "sm" ? "btn-sm" : "",
size === "lg" ? "btn-lg" : "",
block ? "btn-block" : "",
className,
]
.filter(Boolean)
.join(" ");
return (
<button className={classes} disabled={disabled || loading} {...props}>
{loading && <Spinner light={variant === "primary" || variant === "danger"} />}
{children}
</button>
);
}
+54
View File
@@ -0,0 +1,54 @@
export function Field({ label, hint, error, children, htmlFor }) {
return (
<div className="field">
{label && (
<label className="field-label" htmlFor={htmlFor}>
{label}
</label>
)}
{children}
{error ? (
<div className="field-error">{error}</div>
) : hint ? (
<div className="field-hint">{hint}</div>
) : null}
</div>
);
}
export function Input({ error, className = "", ...props }) {
return (
<input
className={`input ${error ? "has-error" : ""} ${className}`}
{...props}
/>
);
}
export function Textarea({ error, mono, className = "", ...props }) {
return (
<textarea
className={`textarea ${mono ? "textarea-mono" : ""} ${
error ? "has-error" : ""
} ${className}`}
{...props}
/>
);
}
export function Select({ className = "", children, ...props }) {
return (
<select className={`select ${className}`} {...props}>
{children}
</select>
);
}
export function Checkbox({ label, checked, onChange, ...props }) {
return (
<label className="checkbox">
<input type="checkbox" checked={checked} onChange={onChange} {...props} />
{label}
</label>
);
}
+178
View File
@@ -0,0 +1,178 @@
const PATHS = {
document: (
<>
<path d="M7 3h7l3 3v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" />
<path d="M14 3v3a1 1 0 0 0 1 1h3" />
<path d="M9 12h6M9 15h6M9 18h4" />
</>
),
book: (
<>
<path d="M5 4h9a2 2 0 0 1 2 2v14H7a2 2 0 0 1-2-2V4z" />
<path d="M5 18h9a2 2 0 0 0 2-2" />
</>
),
image: (
<>
<rect x="4" y="5" width="16" height="14" rx="2" />
<circle cx="9" cy="10" r="1.5" />
<path d="M6 17l4-4 3 3 2-2 3 3" />
</>
),
sparkles: (
<>
<path d="M12 3l1.2 4.2L17 8l-3.8 1.2L12 14l-1.2-4.8L7 8l3.8-0.8L12 3z" />
<path d="M18 14l0.6 2.1L21 17l-2.1 0.6L18 20l-0.6-2.4L15 17l2.4-0.9L18 14z" />
</>
),
help: <><circle cx="12" cy="12" r="9" /><path d="M9.5 9.5a2.5 2.5 0 1 1 4.2 1.8c-.8.7-1.2 1.4-1.2 2.7M12 17h.01" /></>,
upload: (
<>
<path d="M12 16V6M8 10l4-4 4 4" />
<path d="M5 20h14" />
</>
),
clipboard: (
<>
<rect x="7" y="4" width="10" height="4" rx="1" />
<path d="M6 8h12v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V8z" />
</>
),
lock: (
<>
<rect x="6" y="11" width="12" height="9" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
</>
),
folder: (
<>
<path d="M4 7h6l2 2h8v10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7z" />
</>
),
clock: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</>
),
inbox: (
<>
<path d="M4 6h16v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6z" />
<path d="M4 10h5l2 3h2l2-3h5" />
</>
),
compass: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M14.5 9.5L10 14l-2.5-2.5L12 10l2.5-0.5z" />
</>
),
file: (
<>
<path d="M8 4h8l2 2v14H8a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z" />
<path d="M14 4v3h3" />
</>
),
graduation: (
<>
<path d="M3 10l9-5 9 5-9 5-9-5z" />
<path d="M6 12v4c0 1.5 2.7 3 6 3s6-1.5 6-3v-4" />
</>
),
globe: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
</>
),
cpu: (
<>
<rect x="7" y="7" width="10" height="10" rx="1" />
<path d="M9 3v2M15 3v2M9 19v2M15 19v2M3 9h2M3 15h2M19 9h2M19 15h2" />
</>
),
paperclip: (
<path d="M8 12.5V7a4 4 0 0 1 8 0v8a3 3 0 0 1-6 0V8" />
),
close: (
<>
<path d="M6 6l12 12M18 6L6 18" />
</>
),
check: <path d="M5 12l5 5L19 7" />,
x: (
<>
<path d="M8 8l8 8M16 8l-8 8" />
</>
),
info: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v6M12 8h.01" />
</>
),
ban: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M7 7l10 10" />
</>
),
listChecks: (
<>
<path d="M9 6h11M9 12h11M9 18h11" />
<path d="M5 6h.01M5 12h.01M5 18h.01" />
</>
),
toggle: <circle cx="12" cy="12" r="4" />,
pencil: (
<>
<path d="M4 20h4l10-10-4-4L4 16v4z" />
<path d="M14 6l4 4" />
</>
),
link: (
<>
<path d="M10 14a4 4 0 0 1 0-6l1-1a4 4 0 0 1 6 6l-1 1" />
<path d="M14 10a4 4 0 0 1 0 6l-1 1a4 4 0 0 1-6-6l1-1" />
</>
),
download: (
<>
<path d="M12 5v10M8 13l4 4 4-4" />
<path d="M5 19h14" />
</>
),
moodle: (
<>
<path d="M4 18V8l8-4 8 4v10" />
<path d="M8 14l4 3 4-3" />
</>
),
plus: (
<>
<path d="M12 5v14M5 12h14" />
</>
),
};
export default function Icon({ name, size = 18, className = "", strokeWidth = 1.75 }) {
const content = PATHS[name];
if (!content) return null;
return (
<svg
className={`icon ${className}`}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{content}
</svg>
);
}
+26
View File
@@ -0,0 +1,26 @@
import Icon from "./Icon";
export function Badge({ variant, children }) {
return <span className={`badge ${variant ? `badge-${variant}` : ""}`}>{children}</span>;
}
export function EmptyState({ icon = "inbox", title, message, action }) {
return (
<div className="empty-state">
<div className="empty-state-icon icon-wrap icon-box icon-box-lg">
<Icon name={icon} size={28} className="icon-muted" />
</div>
<h3>{title}</h3>
{message && <p>{message}</p>}
{action && <div className="mt">{action}</div>}
</div>
);
}
export function Card({ children, className = "", ...props }) {
return (
<div className={`card ${className}`} {...props}>
{children}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import Button from "./Button";
import Icon from "./Icon";
export default function Modal({ open, onClose, title, children, footer, large }) {
useEffect(() => {
if (!open) return;
const onKey = (e) => e.key === "Escape" && onClose?.();
window.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="modal-overlay" onMouseDown={onClose}>
<div
className={`modal ${large ? "modal-lg" : ""}`}
onMouseDown={(e) => e.stopPropagation()}
>
{title && (
<div className="modal-head">
<h3>{title}</h3>
<span style={{ flex: 1 }} />
<button className="toast-close" type="button" aria-label="Cerrar" onClick={onClose}>
<Icon name="close" size={16} />
</button>
</div>
)}
<div className="modal-body">{children}</div>
{footer && <div className="modal-foot">{footer}</div>}
</div>
</div>,
document.body
);
}
export function ConfirmDialog({
open,
title = "¿Confirmar?",
message,
confirmLabel = "Confirmar",
danger,
loading,
onConfirm,
onClose,
}) {
return (
<Modal
open={open}
onClose={onClose}
title={title}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={loading}>
Cancelar
</Button>
<Button
variant={danger ? "danger" : "primary"}
onClick={onConfirm}
loading={loading}
>
{confirmLabel}
</Button>
</>
}
>
<p className="text-soft" style={{ margin: 0 }}>
{message}
</p>
</Modal>
);
}
+21
View File
@@ -0,0 +1,21 @@
export default function Spinner({ large, light }) {
const classes = [
"spinner",
large ? "spinner-lg" : "",
light ? "spinner-light" : "",
]
.filter(Boolean)
.join(" ");
return <span className={classes} aria-label="Cargando" />;
}
export function SpinnerCenter({ label }) {
return (
<div className="spinner-center">
<div className="text-center">
<Spinner large />
{label && <p className="text-soft mt">{label}</p>}
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import {
clearToken,
getToken,
onUnauthorized,
setToken,
} from "../api/client";
import * as authApi from "../api/auth";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const logout = useCallback(() => {
clearToken();
setUser(null);
}, []);
// Carga inicial: si hay token, intenta recuperar el usuario.
useEffect(() => {
let active = true;
async function bootstrap() {
if (!getToken()) {
setLoading(false);
return;
}
try {
const me = await authApi.getMe();
if (active) setUser(me);
} catch {
clearToken();
} finally {
if (active) setLoading(false);
}
}
bootstrap();
return () => {
active = false;
};
}, []);
// Reacciona a 401 global (token caducado).
useEffect(() => onUnauthorized(() => logout()), [logout]);
const finishLogin = useCallback(async (tokenResponse) => {
setToken(tokenResponse.access_token);
const me = await authApi.getMe();
setUser(me);
return me;
}, []);
const login = useCallback(
async (credentials) => {
const res = await authApi.login(credentials);
return finishLogin(res);
},
[finishLogin]
);
const loginWithGoogle = useCallback(
async (idToken) => {
const res = await authApi.loginWithGoogle(idToken);
return finishLogin(res);
},
[finishLogin]
);
const register = useCallback(async (payload) => {
await authApi.register(payload);
// Tras registrar, iniciamos sesión automáticamente.
const res = await authApi.login({
email: payload.email,
password: payload.password,
});
setToken(res.access_token);
const me = await authApi.getMe();
setUser(me);
return me;
}, []);
const value = {
user,
loading,
isAuthenticated: !!user,
login,
loginWithGoogle,
register,
logout,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth debe usarse dentro de AuthProvider");
return ctx;
}
+68
View File
@@ -0,0 +1,68 @@
import { createContext, useCallback, useContext, useState } from "react";
import Icon from "../components/ui/Icon";
const ToastContext = createContext(null);
let idSeq = 0;
const TOAST_ICONS = { success: "check", error: "x", info: "info" };
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const remove = useCallback((id) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const push = useCallback(
(toast) => {
const id = ++idSeq;
const item = { id, duration: 4500, ...toast };
setToasts((prev) => [...prev, item]);
if (item.duration > 0) {
setTimeout(() => remove(id), item.duration);
}
return id;
},
[remove]
);
const toast = {
success: (msg, title = "Hecho") => push({ type: "success", title, msg }),
error: (msg, title = "Error") => push({ type: "error", title, msg }),
info: (msg, title = "Información") => push({ type: "info", title, msg }),
};
return (
<ToastContext.Provider value={toast}>
{children}
<div className="toast-stack">
{toasts.map((t) => (
<div key={t.id} className={`toast toast-${t.type}`} role="alert">
<span className={`toast-icon icon-${t.type}`}>
<Icon name={TOAST_ICONS[t.type]} size={16} />
</span>
<div className="toast-content">
<div className="toast-title">{t.title}</div>
{t.msg && <div className="toast-msg">{t.msg}</div>}
</div>
<button
className="toast-close"
type="button"
aria-label="Cerrar"
onClick={() => remove(t.id)}
>
<Icon name="close" size={14} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast debe usarse dentro de ToastProvider");
return ctx;
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from "react";
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || "";
const SCRIPT_SRC = "https://accounts.google.com/gsi/client";
/**
* Carga Google Identity Services y renderiza el botón oficial.
* Solo se activa si VITE_GOOGLE_CLIENT_ID está configurado.
* onCredential recibe el id_token que enviaremos a /auth/google.
*/
export function useGoogleSignIn(onCredential) {
const buttonRef = useRef(null);
const [ready, setReady] = useState(false);
const enabled = Boolean(GOOGLE_CLIENT_ID);
const callbackRef = useRef(onCredential);
callbackRef.current = onCredential;
useEffect(() => {
if (!enabled) return;
function init() {
if (!window.google?.accounts?.id) return;
window.google.accounts.id.initialize({
client_id: GOOGLE_CLIENT_ID,
callback: (response) => callbackRef.current?.(response.credential),
});
if (buttonRef.current) {
window.google.accounts.id.renderButton(buttonRef.current, {
theme: "outline",
size: "large",
width: 340,
text: "continue_with",
shape: "rectangular",
});
}
setReady(true);
}
const existing = document.querySelector(`script[src="${SCRIPT_SRC}"]`);
if (existing) {
if (window.google?.accounts?.id) init();
else existing.addEventListener("load", init);
return;
}
const script = document.createElement("script");
script.src = SCRIPT_SRC;
script.async = true;
script.defer = true;
script.onload = init;
document.body.appendChild(script);
}, [enabled]);
return { buttonRef, enabled, ready };
}
+935
View File
@@ -0,0 +1,935 @@
:root {
--c-bg: #f6f7fb;
--c-surface: #ffffff;
--c-surface-2: #f1f2f9;
--c-border: #e4e6ef;
--c-border-strong: #d3d6e6;
--c-text: #1c2030;
--c-text-soft: #5b6275;
--c-text-faint: #8b90a3;
--c-primary: #6366f1;
--c-primary-hover: #4f51e0;
--c-primary-soft: #eef0ff;
--c-primary-text: #ffffff;
--c-success: #16a34a;
--c-success-soft: #e7f6ec;
--c-warn: #d97706;
--c-warn-soft: #fdf2e3;
--c-danger: #dc2626;
--c-danger-soft: #fdeaea;
--c-info: #0ea5e9;
--c-info-soft: #e6f6fd;
--radius-sm: 8px;
--radius: 12px;
--radius-lg: 18px;
--shadow-sm: 0 1px 2px rgba(20, 23, 40, 0.06);
--shadow: 0 6px 24px rgba(20, 23, 40, 0.08);
--shadow-lg: 0 18px 48px rgba(20, 23, 40, 0.16);
--font: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
--maxw: 1180px;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
font-family: var(--font);
background: var(--c-bg);
color: var(--c-text);
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
}
a {
color: var(--c-primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
h1,
h2,
h3,
h4 {
margin: 0 0 0.4em;
line-height: 1.25;
font-weight: 700;
letter-spacing: -0.01em;
}
button {
font-family: inherit;
}
.icon {
display: inline-block;
vertical-align: -0.2em;
flex-shrink: 0;
}
.icon-inline {
margin-right: 6px;
}
.icon-lg {
width: 40px;
height: 40px;
}
.icon-xl {
width: 56px;
height: 56px;
}
.icon-muted {
color: var(--c-text-faint);
}
.icon-primary {
color: var(--c-primary);
}
.icon-success {
color: var(--c-success);
}
.icon-danger {
color: var(--c-danger);
}
.icon-wrap {
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon-box {
width: 42px;
height: 42px;
border-radius: 10px;
background: var(--c-surface-2);
color: var(--c-text-soft);
}
.icon-box-lg {
width: 56px;
height: 56px;
border-radius: 14px;
}
.icon-box-primary {
background: var(--c-primary-soft);
color: var(--c-primary);
}
/* ---------- Layout ---------- */
.app-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
position: sticky;
top: 0;
z-index: 30;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--c-border);
}
.navbar-inner {
max-width: var(--maxw);
margin: 0 auto;
padding: 0 24px;
height: 64px;
display: flex;
align-items: center;
gap: 20px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 800;
font-size: 17px;
color: var(--c-text);
}
.brand:hover {
text-decoration: none;
}
.brand-logo {
width: 32px;
height: 32px;
border-radius: 9px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
flex-shrink: 0;
}
.brand-logo .icon {
color: #fff;
}
.nav-links {
display: flex;
gap: 6px;
margin-left: 8px;
}
.nav-link {
padding: 8px 14px;
border-radius: var(--radius-sm);
color: var(--c-text-soft);
font-weight: 500;
font-size: 14px;
}
.nav-link:hover {
background: var(--c-surface-2);
text-decoration: none;
}
.nav-link.active {
background: var(--c-primary-soft);
color: var(--c-primary);
}
.nav-spacer {
flex: 1;
}
.nav-user {
display: flex;
align-items: center;
gap: 12px;
}
.avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--c-primary-soft);
color: var(--c-primary);
display: grid;
place-items: center;
font-weight: 700;
font-size: 14px;
}
.page {
max-width: var(--maxw);
margin: 0 auto;
padding: 32px 24px 64px;
width: 100%;
flex: 1;
}
.page-narrow {
max-width: 760px;
}
.page-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.page-header h1 {
font-size: 26px;
margin: 0;
}
.page-header p {
margin: 4px 0 0;
color: var(--c-text-soft);
}
.page-header-actions {
margin-left: auto;
display: flex;
gap: 10px;
}
/* ---------- Cards ---------- */
.card {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
}
.card-pad {
padding: 22px;
}
.card-head {
padding: 18px 22px;
border-bottom: 1px solid var(--c-border);
display: flex;
align-items: center;
gap: 12px;
}
.card-head h3 {
margin: 0;
font-size: 16px;
}
.card-body {
padding: 22px;
}
.grid {
display: grid;
gap: 18px;
}
.grid-cards {
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
}
.grid-2 {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 10px 18px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.05s, opacity 0.15s;
white-space: nowrap;
}
.btn:active {
transform: translateY(1px);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-primary {
background: var(--c-primary);
color: var(--c-primary-text);
}
.btn-primary:hover:not(:disabled) {
background: var(--c-primary-hover);
}
.btn-ghost {
background: transparent;
color: var(--c-text-soft);
border-color: var(--c-border-strong);
}
.btn-ghost:hover:not(:disabled) {
background: var(--c-surface-2);
}
.btn-subtle {
background: var(--c-surface-2);
color: var(--c-text);
}
.btn-subtle:hover:not(:disabled) {
background: var(--c-border);
}
.btn-danger {
background: var(--c-danger);
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn-danger-ghost {
background: transparent;
color: var(--c-danger);
border-color: var(--c-danger-soft);
}
.btn-danger-ghost:hover:not(:disabled) {
background: var(--c-danger-soft);
}
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
.btn-lg {
padding: 13px 22px;
font-size: 15px;
}
.btn-block {
width: 100%;
}
.btn-icon {
padding: 8px;
width: 36px;
height: 36px;
}
/* ---------- Forms ---------- */
.field {
margin-bottom: 18px;
}
.field-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--c-text);
margin-bottom: 7px;
}
.field-hint {
font-size: 12.5px;
color: var(--c-text-faint);
margin-top: 6px;
}
.field-error {
font-size: 12.5px;
color: var(--c-danger);
margin-top: 6px;
}
.input,
.textarea,
.select {
width: 100%;
padding: 11px 13px;
border: 1px solid var(--c-border-strong);
border-radius: var(--radius-sm);
font-size: 14px;
font-family: inherit;
color: var(--c-text);
background: var(--c-surface);
transition: border-color 0.15s, box-shadow 0.15s;
}
.input:focus,
.textarea:focus,
.select:focus {
outline: none;
border-color: var(--c-primary);
box-shadow: 0 0 0 3px var(--c-primary-soft);
}
.input.has-error,
.textarea.has-error {
border-color: var(--c-danger);
}
.textarea {
resize: vertical;
min-height: 110px;
line-height: 1.5;
}
.textarea-mono {
font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 13px;
}
.row {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.row > * {
flex: 1;
min-width: 160px;
}
.checkbox {
display: flex;
align-items: center;
gap: 9px;
font-size: 14px;
cursor: pointer;
user-select: none;
}
.checkbox input {
width: 17px;
height: 17px;
accent-color: var(--c-primary);
cursor: pointer;
}
/* ---------- Badges ---------- */
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: var(--c-surface-2);
color: var(--c-text-soft);
}
.badge-primary {
background: var(--c-primary-soft);
color: var(--c-primary);
}
.badge-success {
background: var(--c-success-soft);
color: var(--c-success);
}
.badge-warn {
background: var(--c-warn-soft);
color: var(--c-warn);
}
.badge-danger {
background: var(--c-danger-soft);
color: var(--c-danger);
}
.badge-info {
background: var(--c-info-soft);
color: var(--c-info);
}
/* ---------- Tabs ---------- */
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--c-border);
margin-bottom: 24px;
overflow-x: auto;
}
.tab {
padding: 11px 16px;
border: none;
background: none;
color: var(--c-text-soft);
font-weight: 600;
font-size: 14px;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
white-space: nowrap;
display: flex;
align-items: center;
gap: 7px;
}
.tab:hover {
color: var(--c-text);
}
.tab.active {
color: var(--c-primary);
border-bottom-color: var(--c-primary);
}
.tab-count {
font-size: 11px;
background: var(--c-surface-2);
padding: 1px 7px;
border-radius: 999px;
color: var(--c-text-soft);
}
/* ---------- Auth pages ---------- */
.auth-wrap {
min-height: 100vh;
display: grid;
grid-template-columns: 1fr 1fr;
}
.auth-aside {
background: linear-gradient(150deg, #4f46e5, #7c3aed 55%, #9333ea);
color: #fff;
padding: 56px 52px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.auth-aside h2 {
font-size: 30px;
max-width: 420px;
}
.auth-aside p {
color: rgba(255, 255, 255, 0.82);
max-width: 420px;
font-size: 15px;
}
.auth-feature {
display: flex;
gap: 12px;
align-items: flex-start;
margin-bottom: 18px;
}
.auth-feature-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.16);
display: grid;
place-items: center;
flex-shrink: 0;
}
.auth-main {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 24px;
}
.auth-card {
width: 100%;
max-width: 400px;
}
.auth-card h1 {
font-size: 25px;
}
.auth-sub {
color: var(--c-text-soft);
margin-bottom: 26px;
}
.auth-switch {
text-align: center;
margin-top: 22px;
color: var(--c-text-soft);
font-size: 14px;
}
.divider {
display: flex;
align-items: center;
gap: 12px;
margin: 20px 0;
color: var(--c-text-faint);
font-size: 13px;
}
.divider::before,
.divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--c-border);
}
@media (max-width: 880px) {
.auth-wrap {
grid-template-columns: 1fr;
}
.auth-aside {
display: none;
}
}
/* ---------- Misc ---------- */
.stat-card {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-value {
font-size: 26px;
font-weight: 800;
}
.stat-label {
font-size: 13px;
color: var(--c-text-soft);
}
.template-card {
display: flex;
flex-direction: column;
cursor: pointer;
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s;
}
.template-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow);
border-color: var(--c-border-strong);
}
.template-card-top {
height: 7px;
border-radius: var(--radius) var(--radius) 0 0;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
}
.meta-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
color: var(--c-text-soft);
font-size: 13px;
}
.meta-row span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.list-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--c-border);
border-radius: var(--radius);
background: var(--c-surface);
margin-bottom: 10px;
}
.list-item-icon {
width: 42px;
height: 42px;
border-radius: 10px;
display: grid;
place-items: center;
flex-shrink: 0;
background: var(--c-surface-2);
font-size: 18px;
}
.list-item-main {
flex: 1;
min-width: 0;
}
.list-item-title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.list-item-sub {
font-size: 13px;
color: var(--c-text-faint);
}
.empty-state {
text-align: center;
padding: 56px 24px;
color: var(--c-text-soft);
}
.empty-state-icon {
margin: 0 auto 12px;
}
.spinner {
width: 20px;
height: 20px;
border: 2.5px solid var(--c-border-strong);
border-top-color: var(--c-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.spinner-lg {
width: 38px;
height: 38px;
border-width: 4px;
}
.spinner-center {
display: grid;
place-items: center;
padding: 60px 0;
}
.spinner-light {
border-color: rgba(255, 255, 255, 0.4);
border-top-color: #fff;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---------- Modal ---------- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(20, 23, 40, 0.55);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: grid;
place-items: center;
padding: 20px;
z-index: 1000;
animation: fade 0.15s ease;
}
.modal {
background: var(--c-surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow: auto;
animation: pop 0.16s ease;
}
.modal-lg {
max-width: 760px;
}
.modal-head {
padding: 20px 24px;
border-bottom: 1px solid var(--c-border);
display: flex;
align-items: center;
}
.modal-head h3 {
margin: 0;
font-size: 17px;
}
.modal-body {
padding: 24px;
}
.modal-foot {
padding: 16px 24px;
border-top: 1px solid var(--c-border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
@keyframes fade {
from {
opacity: 0;
}
}
@keyframes pop {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}
/* ---------- Toasts ---------- */
.toast-stack {
position: fixed;
top: 18px;
right: 18px;
z-index: 100;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 380px;
}
.toast {
display: flex;
align-items: flex-start;
gap: 11px;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-left: 4px solid var(--c-text-soft);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 13px 15px;
animation: slidein 0.2s ease;
}
.toast-success {
border-left-color: var(--c-success);
}
.toast-error {
border-left-color: var(--c-danger);
}
.toast-info {
border-left-color: var(--c-info);
}
.toast-icon {
margin-top: 2px;
flex-shrink: 0;
}
.toast-icon.icon-success {
color: var(--c-success);
}
.toast-icon.icon-error {
color: var(--c-danger);
}
.toast-icon.icon-info {
color: var(--c-info);
}
.toast-content {
flex: 1;
font-size: 14px;
}
.toast-title {
font-weight: 600;
margin-bottom: 2px;
}
.toast-msg {
color: var(--c-text-soft);
font-size: 13px;
}
.toast-close {
background: none;
border: none;
color: var(--c-text-faint);
cursor: pointer;
font-size: 16px;
line-height: 1;
}
@keyframes slidein {
from {
opacity: 0;
transform: translateX(20px);
}
}
/* ---------- Utilities ---------- */
.flex {
display: flex;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-sm {
gap: 8px;
}
.gap {
gap: 14px;
}
.wrap {
flex-wrap: wrap;
}
.mt {
margin-top: 16px;
}
.mt-lg {
margin-top: 26px;
}
.mb {
margin-bottom: 16px;
}
.text-soft {
color: var(--c-text-soft);
}
.text-faint {
color: var(--c-text-faint);
}
.text-sm {
font-size: 13px;
}
.text-center {
text-align: center;
}
.mono {
font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 13px;
}
.code-block {
background: #1e2030;
color: #e4e6ef;
border-radius: var(--radius);
padding: 16px;
overflow: auto;
font-family: "JetBrains Mono", "Consolas", monospace;
font-size: 12.5px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
max-height: 420px;
}
.progress {
height: 8px;
background: var(--c-surface-2);
border-radius: 999px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #6366f1, #8b5cf6);
transition: width 0.3s ease;
}
.progress-bar.warn {
background: linear-gradient(90deg, #f59e0b, #f97316);
}
.progress-bar.danger {
background: linear-gradient(90deg, #ef4444, #dc2626);
}
.divider-line {
height: 1px;
background: var(--c-border);
margin: 20px 0;
}
.thumb {
width: 100%;
height: 150px;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--c-surface-2);
border: 1px solid var(--c-border);
}
.thumb-sm {
width: 56px;
height: 56px;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--c-border);
background: var(--c-surface-2);
}
+19
View File
@@ -0,0 +1,19 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { AuthProvider } from "./context/AuthContext";
import { ToastProvider } from "./context/ToastContext";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<ToastProvider>
<AuthProvider>
<App />
</AuthProvider>
</ToastProvider>
</BrowserRouter>
</React.StrictMode>
);
+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>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { listTemplates } from "../api/templates";
import { useToast } from "../context/ToastContext";
import { SpinnerCenter } from "../components/ui/Spinner";
import Button from "../components/ui/Button";
import { Badge, EmptyState } from "../components/ui/Misc";
import { formatLastUpdated } from "../utils/format";
import Icon from "../components/ui/Icon";
export default function DashboardPage() {
const navigate = useNavigate();
const toast = useToast();
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
listTemplates()
.then((data) => active && setTemplates(data))
.catch((err) => toast.error(err.message))
.finally(() => active && setLoading(false));
return () => {
active = false;
};
}, []);
if (loading) return <SpinnerCenter label="Cargando tus exámenes…" />;
return (
<div className="page">
<div className="page-header">
<div>
<h1>Mis exámenes</h1>
<p>Gestiona tus plantillas de examen y genera preguntas con IA.</p>
</div>
<div className="page-header-actions">
<Button onClick={() => navigate("/plantillas/nueva")}>
<Icon name="plus" size={16} className="icon-inline" />
Nuevo examen
</Button>
</div>
</div>
{templates.length === 0 ? (
<div className="card">
<EmptyState
icon="folder"
title="Aún no tienes exámenes"
message="Crea tu primera plantilla para empezar a generar preguntas con IA."
action={
<Button onClick={() => navigate("/plantillas/nueva")}>
Crear mi primer examen
</Button>
}
/>
</div>
) : (
<div className="grid grid-cards">
{templates.map((t) => (
<div
key={t.id}
className="card template-card"
onClick={() => navigate(`/plantillas/${t.id}`)}
>
<div className="template-card-top" />
<div className="card-pad">
<div className="flex justify-between items-center mb">
<Badge variant="primary">{t.subject}</Badge>
<Badge variant={t.question_count > 0 ? "success" : undefined}>
{t.question_count} preg.
</Badge>
</div>
<h3 style={{ marginBottom: 6 }}>{t.title}</h3>
<div className="meta-row mt">
<span className="icon-wrap">
<Icon name="graduation" size={14} className="icon-inline" />
{t.educational_level}
</span>
<span className="icon-wrap">
<Icon name="globe" size={14} className="icon-inline" />
{t.language?.toUpperCase()}
</span>
</div>
<div className="divider-line" />
<div className="text-sm text-faint">
{formatLastUpdated(t.updated_at)}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { useToast } from "../context/ToastContext";
import { useGoogleSignIn } from "../hooks/useGoogleSignIn";
import AuthLayout from "../components/AuthLayout";
import { Field, Input } from "../components/ui/Field";
import Button from "../components/ui/Button";
export default function LoginPage() {
const { login, loginWithGoogle, isAuthenticated } = useAuth();
const toast = useToast();
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/";
const [form, setForm] = useState({ email: "", password: "" });
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (isAuthenticated) navigate(from, { replace: true });
}, [isAuthenticated, from, navigate]);
const onChange = (e) =>
setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
const validate = () => {
const e = {};
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email))
e.email = "Introduce un email válido.";
if (!form.password) e.password = "La contraseña es obligatoria.";
setErrors(e);
return Object.keys(e).length === 0;
};
const submit = async (e) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
try {
await login(form);
toast.success("Sesión iniciada correctamente.");
navigate(from, { replace: true });
} catch (err) {
toast.error(err.message);
} finally {
setLoading(false);
}
};
const onGoogle = async (idToken) => {
try {
await loginWithGoogle(idToken);
toast.success("Sesión iniciada con Google.");
navigate(from, { replace: true });
} catch (err) {
toast.error(err.message);
}
};
const { buttonRef, enabled: googleEnabled } = useGoogleSignIn(onGoogle);
return (
<AuthLayout>
<h1>Bienvenido de nuevo</h1>
<p className="auth-sub">Inicia sesión para gestionar tus exámenes.</p>
<form onSubmit={submit} noValidate>
<Field label="Email" error={errors.email} htmlFor="email">
<Input
id="email"
name="email"
type="email"
placeholder="tu@correo.com"
value={form.email}
onChange={onChange}
error={errors.email}
autoComplete="email"
/>
</Field>
<Field label="Contraseña" error={errors.password} htmlFor="password">
<Input
id="password"
name="password"
type="password"
placeholder="••••••••"
value={form.password}
onChange={onChange}
error={errors.password}
autoComplete="current-password"
/>
</Field>
<Button type="submit" block size="lg" loading={loading}>
Iniciar sesión
</Button>
</form>
{googleEnabled && (
<>
<div className="divider">o continúa con</div>
<div style={{ display: "flex", justifyContent: "center" }}>
<div ref={buttonRef} />
</div>
</>
)}
<p className="auth-switch">
¿No tienes cuenta? <Link to="/registro">Crea una gratis</Link>
</p>
</AuthLayout>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Link } from "react-router-dom";
import Icon from "../components/ui/Icon";
export default function NotFoundPage() {
return (
<div className="page page-narrow text-center" style={{ paddingTop: 90 }}>
<div className="icon-wrap icon-box icon-box-lg" style={{ margin: "0 auto 16px" }}>
<Icon name="compass" size={32} className="icon-muted" />
</div>
<h1 style={{ fontSize: 30 }}>Página no encontrada</h1>
<p className="text-soft">
La página que buscas no existe o ha sido movida.
</p>
<Link to="/" className="btn btn-primary mt">
Volver al inicio
</Link>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { useToast } from "../context/ToastContext";
import { useGoogleSignIn } from "../hooks/useGoogleSignIn";
import AuthLayout from "../components/AuthLayout";
import { Field, Input } from "../components/ui/Field";
import Button from "../components/ui/Button";
export default function RegisterPage() {
const { register, loginWithGoogle } = useAuth();
const toast = useToast();
const navigate = useNavigate();
const [form, setForm] = useState({
full_name: "",
email: "",
password: "",
confirm: "",
});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const onChange = (e) =>
setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
const validate = () => {
const e = {};
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email))
e.email = "Introduce un email válido.";
if (form.password.length < 8)
e.password = "La contraseña debe tener al menos 8 caracteres.";
if (form.password !== form.confirm)
e.confirm = "Las contraseñas no coinciden.";
setErrors(e);
return Object.keys(e).length === 0;
};
const submit = async (e) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
try {
await register({
email: form.email,
password: form.password,
full_name: form.full_name,
});
toast.success("Cuenta creada. ¡Bienvenido!");
navigate("/", { replace: true });
} catch (err) {
toast.error(err.message);
} finally {
setLoading(false);
}
};
const onGoogle = async (idToken) => {
try {
await loginWithGoogle(idToken);
toast.success("Cuenta vinculada con Google.");
navigate("/", { replace: true });
} catch (err) {
toast.error(err.message);
}
};
const { buttonRef, enabled: googleEnabled } = useGoogleSignIn(onGoogle);
return (
<AuthLayout>
<h1>Crea tu cuenta</h1>
<p className="auth-sub">Empieza a generar exámenes en segundos.</p>
<form onSubmit={submit} noValidate>
<Field label="Nombre (opcional)" htmlFor="full_name">
<Input
id="full_name"
name="full_name"
placeholder="Tu nombre"
value={form.full_name}
onChange={onChange}
autoComplete="name"
/>
</Field>
<Field label="Email" error={errors.email} htmlFor="email">
<Input
id="email"
name="email"
type="email"
placeholder="tu@correo.com"
value={form.email}
onChange={onChange}
error={errors.email}
autoComplete="email"
/>
</Field>
<Field
label="Contraseña"
error={errors.password}
hint="Mínimo 8 caracteres."
htmlFor="password"
>
<Input
id="password"
name="password"
type="password"
placeholder="••••••••"
value={form.password}
onChange={onChange}
error={errors.password}
autoComplete="new-password"
/>
</Field>
<Field label="Repite la contraseña" error={errors.confirm} htmlFor="confirm">
<Input
id="confirm"
name="confirm"
type="password"
placeholder="••••••••"
value={form.confirm}
onChange={onChange}
error={errors.confirm}
autoComplete="new-password"
/>
</Field>
<Button type="submit" block size="lg" loading={loading}>
Crear cuenta
</Button>
</form>
{googleEnabled && (
<>
<div className="divider">o regístrate con</div>
<div style={{ display: "flex", justifyContent: "center" }}>
<div ref={buttonRef} />
</div>
</>
)}
<p className="auth-switch">
¿Ya tienes cuenta? <Link to="/login">Inicia sesión</Link>
</p>
</AuthLayout>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
getTemplate,
getTemplateStorage,
listQuestions,
} from "../api/templates";
import { listMaterials } from "../api/materials";
import { listImages } from "../api/images";
import { useToast } from "../context/ToastContext";
import { SpinnerCenter } from "../components/ui/Spinner";
import { Badge } from "../components/ui/Misc";
import OverviewTab from "./template/OverviewTab";
import MaterialsTab from "./template/MaterialsTab";
import ImagesTab from "./template/ImagesTab";
import GenerateTab from "./template/GenerateTab";
import QuestionsTab from "./template/QuestionsTab";
import ExportTab from "./template/ExportTab";
import Icon from "../components/ui/Icon";
const TABS = [
{ id: "overview", label: "Resumen", icon: "clipboard" },
{ id: "materials", label: "Material IA", icon: "book" },
{ id: "images", label: "Imágenes", icon: "image" },
{ id: "generate", label: "Generar", icon: "sparkles" },
{ id: "questions", label: "Preguntas", icon: "help" },
{ id: "export", label: "Exportar", icon: "upload" },
];
export default function TemplateDetailPage() {
const { templateId } = useParams();
const toast = useToast();
const [tab, setTab] = useState("overview");
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [template, setTemplate] = useState(null);
const [storage, setStorage] = useState(null);
const [materials, setMaterials] = useState([]);
const [images, setImages] = useState([]);
const [questions, setQuestions] = useState([]);
const reloadStorage = useCallback(async () => {
try {
setStorage(await getTemplateStorage(templateId));
} catch {
/* silencioso */
}
}, [templateId]);
const reloadMaterials = useCallback(async () => {
setMaterials(await listMaterials(templateId));
}, [templateId]);
const reloadImages = useCallback(async () => {
setImages(await listImages(templateId));
}, [templateId]);
const reloadQuestions = useCallback(async () => {
setQuestions(await listQuestions(templateId));
}, [templateId]);
const reloadTemplate = useCallback(async () => {
setTemplate(await getTemplate(templateId));
}, [templateId]);
useEffect(() => {
let active = true;
setLoading(true);
Promise.all([
getTemplate(templateId),
getTemplateStorage(templateId).catch(() => null),
listMaterials(templateId).catch(() => []),
listImages(templateId).catch(() => []),
listQuestions(templateId).catch(() => []),
])
.then(([tpl, stg, mats, imgs, qs]) => {
if (!active) return;
setTemplate(tpl);
setStorage(stg);
setMaterials(mats);
setImages(imgs);
setQuestions(qs);
})
.catch((err) => {
if (!active) return;
if (err.status === 404 || err.status === 403) setNotFound(true);
else toast.error(err.message);
})
.finally(() => active && setLoading(false));
return () => {
active = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [templateId]);
if (loading) return <SpinnerCenter label="Cargando examen…" />;
if (notFound) {
return (
<div className="page page-narrow text-center" style={{ paddingTop: 70 }}>
<div className="icon-wrap icon-box icon-box-lg" style={{ margin: "0 auto 16px" }}>
<Icon name="lock" size={32} className="icon-muted" />
</div>
<h1>Examen no disponible</h1>
<p className="text-soft">
No existe o no tienes permiso para verlo.
</p>
<Link to="/" className="btn btn-primary mt">
Volver a mis exámenes
</Link>
</div>
);
}
const shared = {
templateId,
template,
storage,
materials,
images,
questions,
reloadStorage,
reloadMaterials,
reloadImages,
reloadQuestions,
reloadTemplate,
goToTab: setTab,
};
return (
<div className="page">
<div className="text-sm text-faint mb">
<Link to="/">Mis exámenes</Link> / {template.title}
</div>
<div className="page-header">
<div>
<h1>{template.title}</h1>
<div className="meta-row mt">
<Badge variant="primary">{template.subject}</Badge>
<span className="icon-wrap">
<Icon name="graduation" size={14} className="icon-inline" />
{template.educational_level}
</span>
<span className="icon-wrap">
<Icon name="globe" size={14} className="icon-inline" />
{template.language?.toUpperCase()}
</span>
<Badge variant={questions.length > 0 ? "success" : undefined}>
{questions.length} preguntas
</Badge>
</div>
</div>
</div>
<div className="tabs">
{TABS.map((t) => (
<button
key={t.id}
className={`tab ${tab === t.id ? "active" : ""}`}
onClick={() => setTab(t.id)}
>
<Icon name={t.icon} size={16} />
{t.label}
{t.id === "materials" && materials.length > 0 && (
<span className="tab-count">{materials.length}</span>
)}
{t.id === "images" && images.length > 0 && (
<span className="tab-count">{images.length}</span>
)}
{t.id === "questions" && questions.length > 0 && (
<span className="tab-count">{questions.length}</span>
)}
</button>
))}
</div>
{tab === "overview" && <OverviewTab {...shared} />}
{tab === "materials" && <MaterialsTab {...shared} />}
{tab === "images" && <ImagesTab {...shared} />}
{tab === "generate" && <GenerateTab {...shared} />}
{tab === "questions" && <QuestionsTab {...shared} />}
{tab === "export" && <ExportTab {...shared} />}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { useState } from "react";
import { fetchExport, downloadString } from "../../api/exports";
import { useToast } from "../../context/ToastContext";
import Button from "../../components/ui/Button";
import { EmptyState } from "../../components/ui/Misc";
import Icon from "../../components/ui/Icon";
const FORMATS = [
{
id: "xml",
title: "Moodle XML",
icon: "moodle",
desc: "Importable directamente en un banco de preguntas de Moodle. Incluye imágenes embebidas.",
mime: "application/xml",
},
{
id: "txt",
title: "Texto plano",
icon: "file",
desc: "Listado simple de enunciados y respuestas para revisión rápida.",
mime: "text/plain",
},
{
id: "json",
title: "JSON",
icon: "document",
desc: "Estructura completa de las preguntas para integraciones o copias de seguridad.",
mime: "application/json",
},
];
export default function ExportTab({ templateId, template, questions }) {
const toast = useToast();
const [loadingFormat, setLoadingFormat] = useState(null);
const [preview, setPreview] = useState(null);
const slug = (template.title || "examen")
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 50);
const run = async (fmt, { download }) => {
setLoadingFormat(fmt.id);
try {
const content = await fetchExport(templateId, fmt.id);
if (download) {
downloadString(content, `${slug}.${fmt.id}`, fmt.mime);
toast.success(`Examen exportado como ${fmt.title}.`);
} else {
setPreview({ fmt, content });
}
} catch (err) {
toast.error(err.message);
} finally {
setLoadingFormat(null);
}
};
if (questions.length === 0) {
return (
<div className="card">
<EmptyState
icon="upload"
title="Nada que exportar todavía"
message="Genera preguntas antes de exportar el examen."
/>
</div>
);
}
return (
<div>
<p className="text-soft mb">
Tu examen tiene <strong>{questions.length} preguntas</strong>. Elige un
formato para descargarlo o previsualizarlo.
</p>
<div className="grid grid-cards">
{FORMATS.map((fmt) => (
<div key={fmt.id} className="card card-pad">
<div className="icon-wrap icon-box icon-box-lg" style={{ marginBottom: 8 }}>
<Icon name={fmt.icon} size={28} className="icon-primary" />
</div>
<h3 style={{ margin: "8px 0 4px" }}>{fmt.title}</h3>
<p className="text-sm text-soft" style={{ minHeight: 60 }}>
{fmt.desc}
</p>
<div className="flex gap-sm">
<Button
onClick={() => run(fmt, { download: true })}
loading={loadingFormat === fmt.id}
>
Descargar
</Button>
<Button
variant="ghost"
onClick={() => run(fmt, { download: false })}
disabled={loadingFormat === fmt.id}
>
Previsualizar
</Button>
</div>
</div>
))}
</div>
{preview && (
<div className="card mt-lg">
<div className="card-head">
<h3>Vista previa · {preview.fmt.title}</h3>
<span style={{ flex: 1 }} />
<Button
size="sm"
variant="subtle"
onClick={() =>
downloadString(
preview.content,
`${slug}.${preview.fmt.id}`,
preview.fmt.mime
)
}
>
Descargar
</Button>
<Button size="sm" variant="ghost" onClick={() => setPreview(null)}>
Cerrar
</Button>
</div>
<div className="card-body">
<div className="code-block">{preview.content}</div>
</div>
</div>
)}
</div>
);
}
+302
View File
@@ -0,0 +1,302 @@
import { useState } from "react";
import { buildPrompt, generateExam, parseOutput } from "../../api/generation";
import { useToast } from "../../context/ToastContext";
import Button from "../../components/ui/Button";
import { Field, Textarea, Select, Checkbox } from "../../components/ui/Field";
import { Badge, EmptyState } from "../../components/ui/Misc";
import QuestionCard from "../../components/QuestionCard";
import { totalQuestionsFromProfile } from "../../utils/format";
import Icon from "../../components/ui/Icon";
const MODES = [
{ id: "auto", label: "Generación automática", icon: "sparkles" },
{ id: "prompt", label: "Solo prompt", icon: "document" },
{ id: "parse", label: "Pegar respuesta IA", icon: "download" },
];
export default function GenerateTab({
templateId,
template,
materials,
reloadQuestions,
reloadTemplate,
goToTab,
}) {
const toast = useToast();
const [mode, setMode] = useState("auto");
const [topic, setTopic] = useState("");
const [useAllMaterials, setUseAllMaterials] = useState(true);
const [selectedMaterials, setSelectedMaterials] = useState([]);
const [loading, setLoading] = useState(false);
const [prompt, setPrompt] = useState("");
const [rawOutput, setRawOutput] = useState("");
const [inputFormat, setInputFormat] = useState("json");
const [generated, setGenerated] = useState([]);
const processedMaterials = materials.filter((m) => m.status === "processed");
const expectedTotal = totalQuestionsFromProfile(template.difficulty_profile);
const materialIds = useAllMaterials ? null : selectedMaterials;
const toggleMaterial = (id) =>
setSelectedMaterials((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
const onBuildPrompt = async () => {
setLoading(true);
try {
const res = await buildPrompt(templateId, {
topic_prompt: topic,
material_ids: materialIds,
});
setPrompt(res.prompt);
toast.success("Prompt generado. Cópialo en tu LLM preferido.");
} catch (err) {
toast.error(err.message);
} finally {
setLoading(false);
}
};
const onGenerate = async () => {
setLoading(true);
try {
const res = await generateExam({
template_id: templateId,
topic_prompt: topic,
material_ids: materialIds,
});
setGenerated(res.questions || []);
await Promise.all([reloadQuestions(), reloadTemplate()]);
toast.success(`Se generaron ${res.questions?.length || 0} preguntas.`);
} catch (err) {
toast.error(err.message);
} finally {
setLoading(false);
}
};
const onParse = async () => {
setLoading(true);
try {
const res = await parseOutput({
template_id: templateId,
raw_output: rawOutput,
input_format: inputFormat,
});
setGenerated(res.questions || []);
await Promise.all([reloadQuestions(), reloadTemplate()]);
toast.success(`Se importaron ${res.questions?.length || 0} preguntas.`);
} catch (err) {
toast.error(err.message);
} finally {
setLoading(false);
}
};
const copyPrompt = () => {
navigator.clipboard?.writeText(prompt);
toast.info("Prompt copiado al portapapeles.");
};
const topicTooShort = topic.trim().length < 5;
return (
<div className="grid" style={{ gridTemplateColumns: "1fr 340px" }}>
<div>
<div className="tabs" style={{ marginBottom: 18 }}>
{MODES.map((m) => (
<button
key={m.id}
className={`tab ${mode === m.id ? "active" : ""}`}
onClick={() => setMode(m.id)}
>
<Icon name={m.icon} size={16} />
{m.label}
</button>
))}
</div>
{mode !== "parse" && (
<div className="card mb">
<div className="card-body">
<Field
label="Tema / instrucciones para la IA"
hint="Describe el contenido o enfoque del examen (mínimo 5 caracteres)."
error={topicTooShort && topic.length > 0 ? "Escribe al menos 5 caracteres." : null}
>
<Textarea
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="Ej. Genera preguntas sobre la gestión de procesos y planificación de CPU del Tema 3."
maxLength={4000}
/>
</Field>
{processedMaterials.length > 0 && (
<Field label="Material de contexto">
<Checkbox
label={`Usar todo el material procesado (${processedMaterials.length})`}
checked={useAllMaterials}
onChange={(e) => setUseAllMaterials(e.target.checked)}
/>
{!useAllMaterials && (
<div className="mt flex" style={{ flexDirection: "column", gap: 8 }}>
{processedMaterials.map((m) => (
<Checkbox
key={m.id}
label={m.original_filename}
checked={selectedMaterials.includes(m.id)}
onChange={() => toggleMaterial(m.id)}
/>
))}
</div>
)}
</Field>
)}
<div className="flex gap mt">
{mode === "auto" ? (
<Button
size="lg"
onClick={onGenerate}
loading={loading}
disabled={topicTooShort}
>
<Icon name="sparkles" size={16} className="icon-inline" />
Generar preguntas
</Button>
) : (
<Button
size="lg"
onClick={onBuildPrompt}
loading={loading}
disabled={topicTooShort}
>
<Icon name="document" size={16} className="icon-inline" />
Construir prompt
</Button>
)}
</div>
</div>
</div>
)}
{mode === "prompt" && prompt && (
<div className="card mb">
<div className="card-head">
<h3>Prompt generado</h3>
<span style={{ flex: 1 }} />
<Button size="sm" variant="subtle" onClick={copyPrompt}>
Copiar
</Button>
</div>
<div className="card-body">
<div className="code-block">{prompt}</div>
<p className="field-hint">
Pega este prompt en tu LLM, copia su respuesta JSON y vuelve con
el modo <strong>Pegar respuesta IA</strong> para importar las
preguntas.
</p>
</div>
</div>
)}
{mode === "parse" && (
<div className="card mb">
<div className="card-body">
<Field label="Formato de entrada">
<Select
value={inputFormat}
onChange={(e) => setInputFormat(e.target.value)}
>
<option value="json">JSON (recomendado)</option>
<option value="txt">Texto plano</option>
</Select>
</Field>
<Field
label="Respuesta de la IA"
hint="Pega aquí la salida del LLM."
>
<Textarea
mono
value={rawOutput}
onChange={(e) => setRawOutput(e.target.value)}
placeholder='{ "questions": [ ... ] }'
style={{ minHeight: 220 }}
maxLength={200000}
/>
</Field>
<Button
size="lg"
onClick={onParse}
loading={loading}
disabled={rawOutput.trim().length < 5}
>
<Icon name="download" size={16} className="icon-inline" />
Importar preguntas
</Button>
</div>
</div>
)}
{generated.length > 0 && (
<div className="mt-lg">
<div className="flex justify-between items-center mb">
<h3 style={{ margin: 0 }}>
Resultado ({generated.length} preguntas)
</h3>
<Button variant="subtle" size="sm" onClick={() => goToTab("questions")}>
Ver todas las preguntas
</Button>
</div>
{generated.map((q, i) => (
<QuestionCard key={q.id || i} question={q} index={i + 1} />
))}
</div>
)}
</div>
<div>
<div className="card mb">
<div className="card-head">
<h3>Resumen del objetivo</h3>
</div>
<div className="card-body flex" style={{ flexDirection: "column", gap: 10 }}>
<div className="flex justify-between">
<span className="text-soft">Preguntas objetivo</span>
<Badge variant="primary">{expectedTotal}</Badge>
</div>
<div className="flex justify-between">
<span className="text-soft">Material procesado</span>
<Badge variant={processedMaterials.length ? "success" : undefined}>
{processedMaterials.length}
</Badge>
</div>
</div>
</div>
<div className="card">
<div className="card-body text-sm text-soft">
<strong>¿Cómo funciona?</strong>
<ul style={{ paddingLeft: 18, margin: "8px 0 0" }}>
<li>
<strong>Automática:</strong> el backend llama al LLM y guarda las
preguntas.
</li>
<li>
<strong>Solo prompt:</strong> obtienes el prompt para usarlo en
otro LLM.
</li>
<li>
<strong>Pegar respuesta:</strong> importas la salida JSON/TXT de
la IA.
</li>
</ul>
</div>
</div>
</div>
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
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>
);
}
@@ -0,0 +1,179 @@
import { useState } from "react";
import { uploadMaterial, deleteMaterial } from "../../api/materials";
import { useToast } from "../../context/ToastContext";
import FileDropzone from "../../components/FileDropzone";
import StorageBar from "../../components/StorageBar";
import Button from "../../components/ui/Button";
import { Badge, EmptyState } from "../../components/ui/Misc";
import { ConfirmDialog } from "../../components/ui/Modal";
import Spinner from "../../components/ui/Spinner";
import { MATERIAL_ACCEPT } from "../../utils/constants";
import { formatBytes, formatDate } from "../../utils/format";
import Icon from "../../components/ui/Icon";
const STATUS_BADGE = {
processed: { variant: "success", label: "Procesado" },
pending: { variant: "warn", label: "Pendiente" },
failed: { variant: "danger", label: "Error de extracción" },
};
export default function MaterialsTab({
templateId,
materials,
storage,
reloadMaterials,
reloadStorage,
}) {
const toast = useToast();
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [toDelete, setToDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
const handleUpload = async (file) => {
setUploading(true);
setProgress(0);
try {
const res = await uploadMaterial(templateId, file, setProgress);
await Promise.all([reloadMaterials(), reloadStorage()]);
if (res.material?.status === "failed") {
toast.info(
"El archivo se subió pero no se pudo extraer texto. Aún cuenta para el contexto si lo reintentas con otro formato.",
"Subido con avisos"
);
} else {
toast.success("Material subido y procesado.");
}
} catch (err) {
toast.error(err.message);
} finally {
setUploading(false);
setProgress(0);
}
};
const confirmDelete = async () => {
setDeleting(true);
try {
await deleteMaterial(templateId, toDelete.id);
await Promise.all([reloadMaterials(), reloadStorage()]);
toast.success("Material eliminado.");
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">
{uploading ? (
<div className="text-center" style={{ padding: 24 }}>
<Spinner large />
<p className="text-soft mt">Subiendo y procesando {progress}%</p>
<div className="progress mt">
<div className="progress-bar" style={{ width: `${progress}%` }} />
</div>
</div>
) : (
<FileDropzone
accept={MATERIAL_ACCEPT}
icon="book"
hint="PDF, DOCX, TXT, MD o imágenes (se extrae el texto, también por OCR)."
onFile={handleUpload}
/>
)}
</div>
</div>
{materials.length === 0 ? (
<div className="card">
<EmptyState
icon="inbox"
title="Sin material todavía"
message="Sube documentos para que la IA genere preguntas basadas en su contenido."
/>
</div>
) : (
materials.map((m) => {
const badge = STATUS_BADGE[m.status] || STATUS_BADGE.pending;
return (
<div key={m.id} className="list-item">
<div className="list-item-icon icon-wrap icon-box">
<Icon name="file" size={20} />
</div>
<div className="list-item-main">
<div className="list-item-title">{m.original_filename}</div>
<div className="list-item-sub">
{formatBytes(m.size_bytes)} · {formatDate(m.created_at)}
</div>
{m.status === "failed" && m.error_message && (
<div className="field-error">{m.error_message}</div>
)}
{m.text_preview && (
<div
className="text-sm text-faint"
style={{
marginTop: 6,
maxHeight: 40,
overflow: "hidden",
}}
>
{m.text_preview}
</div>
)}
</div>
<div className="flex gap-sm items-center" style={{ flex: "none" }}>
<Badge variant={badge.variant}>{badge.label}</Badge>
<Button
variant="danger-ghost"
size="sm"
onClick={() => setToDelete(m)}
>
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>¿Para qué sirve?</strong>
<p style={{ marginBottom: 0 }}>
El texto de estos archivos se usa como contexto en el prompt de la
IA. No se muestran en el examen; para imágenes visibles usa la
pestaña <strong>Imágenes</strong>.
</p>
</div>
</div>
</div>
<ConfirmDialog
open={!!toDelete}
title="Eliminar material"
message={`¿Eliminar "${toDelete?.original_filename}"? Esta acción no se puede deshacer.`}
confirmLabel="Eliminar"
danger
loading={deleting}
onConfirm={confirmDelete}
onClose={() => setToDelete(null)}
/>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { Badge } from "../../components/ui/Misc";
import Button from "../../components/ui/Button";
import StorageBar from "../../components/StorageBar";
import {
QUESTION_TYPE_LABEL,
DIFFICULTY_LABEL,
} from "../../utils/constants";
import { formatDate } from "../../utils/format";
import Icon from "../../components/ui/Icon";
export default function OverviewTab({ template, storage, goToTab }) {
const qTypes = template.settings?.question_types || [];
const profile = template.difficulty_profile || {};
return (
<div className="grid" style={{ gridTemplateColumns: "1.4fr 1fr" }}>
<div>
<div className="card mb">
<div className="card-head">
<h3>Estructura del examen</h3>
</div>
<div className="card-body">
<h4 className="text-soft text-sm">Tipos de pregunta</h4>
{qTypes.map((qt, i) => (
<div
key={i}
className="flex justify-between items-center"
style={{
padding: "10px 0",
borderBottom:
i < qTypes.length - 1 ? "1px solid var(--c-border)" : "none",
}}
>
<div>
<strong>{QUESTION_TYPE_LABEL[qt.type] || qt.type}</strong>
{qt.type === "multichoice" && qt.options_count && (
<span className="text-faint text-sm">
{" "}· {qt.options_count} opciones
{qt.multiple_correct ? " · multi-respuesta" : ""}
</span>
)}
</div>
<div className="flex gap-sm items-center">
<Badge variant="primary">{qt.count} preg.</Badge>
<span className="text-sm text-faint">
{qt.score} pt{qt.penalty ? ` · -${qt.penalty}` : ""}
</span>
</div>
</div>
))}
<div className="divider-line" />
<h4 className="text-soft text-sm">Reparto por dificultad</h4>
<div className="flex gap-sm wrap">
{Object.entries(profile).map(([key, val]) =>
val > 0 ? (
<Badge key={key} variant={DIFFICULTY_LABEL[key]?.badge?.replace("badge-", "")}>
{DIFFICULTY_LABEL[key]?.label || key}: {val}
</Badge>
) : null
)}
</div>
<div className="divider-line" />
<div className="flex gap-sm wrap text-sm">
<Badge variant={template.settings?.shuffle_questions ? "success" : undefined}>
<Icon
name={template.settings?.shuffle_questions ? "check" : "x"}
size={12}
className="icon-inline"
/>
Barajar preguntas
</Badge>
<Badge variant={template.settings?.shuffle_answers ? "success" : undefined}>
<Icon
name={template.settings?.shuffle_answers ? "check" : "x"}
size={12}
className="icon-inline"
/>
Barajar respuestas
</Badge>
<Badge variant={template.settings?.include_feedback ? "success" : undefined}>
<Icon
name={template.settings?.include_feedback ? "check" : "x"}
size={12}
className="icon-inline"
/>
Feedback
</Badge>
</div>
</div>
</div>
</div>
<div>
<div className="card mb">
<div className="card-head">
<h3>Almacenamiento</h3>
</div>
<div className="card-body">
<StorageBar storage={storage} />
</div>
</div>
<div className="card mb">
<div className="card-head">
<h3>Siguiente paso</h3>
</div>
<div className="card-body flex" style={{ flexDirection: "column", gap: 10 }}>
<Button variant="subtle" block onClick={() => goToTab("materials")}>
<Icon name="book" size={16} className="icon-inline" />
Subir material para la IA
</Button>
<Button variant="subtle" block onClick={() => goToTab("images")}>
<Icon name="image" size={16} className="icon-inline" />
Añadir imágenes
</Button>
<Button block onClick={() => goToTab("generate")}>
<Icon name="sparkles" size={16} className="icon-inline" />
Generar preguntas
</Button>
</div>
</div>
<div className="card">
<div className="card-body text-sm text-faint">
Creado el {formatDate(template.created_at)}
<br />
Actualizado el {formatDate(template.updated_at)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,143 @@
import { useState } from "react";
import { attachImageToQuestion } from "../../api/questions";
import { useToast } from "../../context/ToastContext";
import QuestionCard from "../../components/QuestionCard";
import AuthImage from "../../components/AuthImage";
import Button from "../../components/ui/Button";
import { EmptyState } from "../../components/ui/Misc";
import Modal from "../../components/ui/Modal";
import Icon from "../../components/ui/Icon";
export default function QuestionsTab({
questions,
images,
reloadQuestions,
goToTab,
}) {
const toast = useToast();
const [editing, setEditing] = useState(null);
const [saving, setSaving] = useState(false);
const setImage = async (questionId, imageId) => {
setSaving(true);
try {
await attachImageToQuestion(questionId, imageId);
await reloadQuestions();
toast.success(imageId ? "Imagen vinculada." : "Imagen desvinculada.");
setEditing(null);
} catch (err) {
toast.error(err.message);
} finally {
setSaving(false);
}
};
if (questions.length === 0) {
return (
<div className="card">
<EmptyState
icon="help"
title="Aún no hay preguntas"
message="Genera o importa preguntas desde la pestaña Generar."
action={
<Button onClick={() => goToTab("generate")}>
<Icon name="sparkles" size={16} className="icon-inline" />
Generar preguntas
</Button>
}
/>
</div>
);
}
return (
<div>
<div className="flex justify-between items-center mb">
<p className="text-soft" style={{ margin: 0 }}>
{questions.length} preguntas guardadas. Vincula imágenes a las
preguntas que las necesiten.
</p>
<Button variant="subtle" size="sm" onClick={() => goToTab("export")}>
Exportar examen
</Button>
</div>
{questions.map((q, i) => (
<QuestionCard
key={q.id}
question={q}
index={i + 1}
footer={
<Button
variant="ghost"
size="sm"
onClick={() => setEditing(q)}
disabled={images.length === 0}
>
{q.image_id ? "Cambiar imagen" : "Vincular imagen"}
</Button>
}
/>
))}
{images.length === 0 && (
<p className="text-sm text-faint text-center">
Sube imágenes en la pestaña <strong>Imágenes</strong> para poder
vincularlas a las preguntas.
</p>
)}
<Modal
open={!!editing}
onClose={() => !saving && setEditing(null)}
title="Vincular imagen a la pregunta"
large
>
<p className="text-soft text-sm">{editing?.statement}</p>
<div className="grid grid-cards mt">
<button
className="card card-pad"
style={{
cursor: "pointer",
textAlign: "center",
border: editing?.image_id
? "1px solid var(--c-border)"
: "2px solid var(--c-primary)",
background: "none",
}}
disabled={saving}
onClick={() => setImage(editing.id, null)}
>
<div className="icon-wrap icon-box" style={{ margin: "0 auto 8px" }}>
<Icon name="ban" size={24} className="icon-muted" />
</div>
Sin imagen
</button>
{images.map((img) => {
const selected = editing?.image_id === img.id;
return (
<button
key={img.id}
className="card card-pad"
style={{
cursor: "pointer",
border: selected
? "2px solid var(--c-primary)"
: "1px solid var(--c-border)",
background: "none",
}}
disabled={saving}
onClick={() => setImage(editing.id, img.id)}
>
<AuthImage imageId={img.id} alt={img.original_filename} />
<div className="text-sm" style={{ marginTop: 8 }}>
{img.caption || img.original_filename}
</div>
</button>
);
})}
</div>
</Modal>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
export const QUESTION_TYPES = [
{ value: "multichoice", label: "Opción múltiple", icon: "listChecks" },
{ value: "truefalse", label: "Verdadero / Falso", icon: "toggle" },
{ value: "shortanswer", label: "Respuesta corta", icon: "pencil" },
{ value: "matching", label: "Emparejamiento", icon: "link" },
];
export const QUESTION_TYPE_LABEL = QUESTION_TYPES.reduce((acc, t) => {
acc[t.value] = t.label;
return acc;
}, {});
export const DIFFICULTIES = [
{ value: "easy", label: "Fácil", badge: "badge-success" },
{ value: "medium", label: "Media", badge: "badge-info" },
{ value: "hard", label: "Difícil", badge: "badge-warn" },
{ value: "very_hard", label: "Muy difícil", badge: "badge-danger" },
];
export const DIFFICULTY_LABEL = DIFFICULTIES.reduce((acc, d) => {
acc[d.value] = d;
return acc;
}, {});
export const MATERIAL_ACCEPT = ".pdf,.docx,.txt,.md,.png,.jpg,.jpeg,.webp";
export const IMAGE_ACCEPT = ".png,.jpg,.jpeg,.webp,.gif";
+68
View File
@@ -0,0 +1,68 @@
export function formatBytes(bytes) {
if (bytes == null) return "—";
if (bytes < 1024) return `${bytes} B`;
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(2)} MB`;
}
export function formatDate(iso) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleString("es-ES", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
export function formatLastUpdated(iso) {
if (!iso) return "Última: —";
try {
const formatted = new Date(iso).toLocaleString("es-ES", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return `Última: ${formatted.replace(/\./g, "")}`;
} catch {
return "Última: —";
}
}
export function formatDateShort(iso) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("es-ES", {
day: "2-digit",
month: "short",
year: "numeric",
});
} catch {
return iso;
}
}
export function initials(text) {
if (!text) return "?";
const parts = text.trim().split(/[\s@.]+/).filter(Boolean);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
export function totalQuestionsFromProfile(profile) {
if (!profile) return 0;
return (
(profile.easy || 0) +
(profile.medium || 0) +
(profile.hard || 0) +
(profile.very_hard || 0)
);
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
},
});