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:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.git
|
||||
@@ -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=
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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;"]
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+2029
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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: [...] }
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user