Merge pull request #5 from uja-dev-practices/feature/backend-v3

Add React frontend and Sinbad2IA LLM integration.
This commit is contained in:
Mireya Cueto Garrido
2026-06-01 13:29:18 +02:00
66 changed files with 6769 additions and 48 deletions
+5 -4
View File
@@ -11,7 +11,7 @@ El proyecto está centrado en backend. La carpeta `frontend` se mantiene vacía
- FastAPI - FastAPI
- PostgreSQL - PostgreSQL
- SQLAlchemy - SQLAlchemy
- Cliente LLM compatible con OpenAI Chat Completions - Cliente LLM para Sinbad2IA UJA (`POST /api/chat`, modelo `qwen3.5:35b`)
- Docker Compose con servicios `backend`, `frontend` y `db` - Docker Compose con servicios `backend`, `frontend` y `db`
## Puesta en Marcha ## Puesta en Marcha
@@ -44,9 +44,10 @@ Variables principales:
- `JWT_EXPIRE_MINUTES`: duración del token de acceso. - `JWT_EXPIRE_MINUTES`: duración del token de acceso.
- `GOOGLE_CLIENT_ID`: Client ID de OAuth 2.0 en Google Cloud Console (para `/auth/google`). - `GOOGLE_CLIENT_ID`: Client ID de OAuth 2.0 en Google Cloud Console (para `/auth/google`).
- `DATABASE_URL`: conexión PostgreSQL usada por el backend. - `DATABASE_URL`: conexión PostgreSQL usada por el backend.
- `LLM_API_KEY`: clave del proveedor LLM. - `LLM_BASE_URL`: URL base del servidor (por defecto ``; el cliente usa `/api/chat`).
- `LLM_BASE_URL`: endpoint compatible con OpenAI. - `LLM_MODEL`: modelo (por defecto `qwen3.5:35b`).
- `LLM_MODEL`: modelo usado para generar preguntas. - `LLM_TIMEOUT_SECONDS`: tiempo máximo de espera (por defecto 180 s).
- `LLM_API_KEY`: opcional, solo si el servidor exige autenticación.
- `ALLOWED_ORIGINS`: orígenes permitidos por CORS. - `ALLOWED_ORIGINS`: orígenes permitidos por CORS.
- `MAX_STORAGE_BYTES_PER_TEMPLATE`: cupo total de almacenamiento por examen (materiales + imágenes). - `MAX_STORAGE_BYTES_PER_TEMPLATE`: cupo total de almacenamiento por examen (materiales + imágenes).
+8 -6
View File
@@ -9,7 +9,7 @@ API_KEY=change-me-in-production-min-16-chars
DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
# --- CORS (orígenes del frontend, separados por coma) --- # --- CORS (orígenes del frontend, separados por coma) ---
ALLOWED_ORIGINS=http://localhost:3000 ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
# --- Rate limiting y tamaño de petición --- # --- Rate limiting y tamaño de petición ---
RATE_LIMIT_REQUESTS=60 RATE_LIMIT_REQUESTS=60
@@ -39,8 +39,10 @@ JWT_EXPIRE_MINUTES=1440
# El frontend obtiene un id_token con Google Identity Services y lo envía a POST /auth/google. # El frontend obtiene un id_token con Google Identity Services y lo envía a POST /auth/google.
GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com
# --- LLM (OpenAI o compatible) --- # --- LLM (Sinbad2IA UJA — sin clave) ---
LLM_API_KEY=sk-your-openai-api-key # URL base del servidor; el cliente llama a {LLM_BASE_URL}/api/chat
LLM_BASE_URL=https://api.openai.com/v1 LLM_BASE_URL=
LLM_MODEL=gpt-4o-mini LLM_MODEL=qwen3.5:35b
LLM_TIMEOUT_SECONDS=60 LLM_TIMEOUT_SECONDS=180
# Opcional, solo si el servidor exige autenticación:
# LLM_API_KEY=
+10 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, status
from app.api.dependencies import get_exam_service, get_storage_quota_service from app.api.dependencies import get_exam_service, get_storage_quota_service
from app.core.auth import get_current_user from app.core.auth import get_current_user
from app.models.user import User from app.models.user import User
from app.schemas.exam import ExamTemplateCreate, ExamTemplateRead from app.schemas.exam import ExamTemplateCreate, ExamTemplateRead, QuestionRead
from app.schemas.storage import TemplateStorageUsage from app.schemas.storage import TemplateStorageUsage
from app.services.exam_service import ExamService from app.services.exam_service import ExamService
from app.services.storage_quota import StorageQuotaService from app.services.storage_quota import StorageQuotaService
@@ -40,6 +40,15 @@ def get_template(
return service.get_template(current_user.id, template_id) return service.get_template(current_user.id, template_id)
@router.get("/{template_id}/questions", response_model=list[QuestionRead])
def list_template_questions(
template_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
service: Annotated[ExamService, Depends(get_exam_service)],
) -> list[QuestionRead]:
return service.list_questions(current_user.id, template_id)
@router.get("/{template_id}/storage", response_model=TemplateStorageUsage) @router.get("/{template_id}/storage", response_model=TemplateStorageUsage)
def get_template_storage_usage( def get_template_storage_usage(
template_id: uuid.UUID, template_id: uuid.UUID,
+4 -4
View File
@@ -10,14 +10,14 @@ class Settings(BaseSettings):
api_prefix: str = "" api_prefix: str = ""
api_key: str = Field(min_length=16) api_key: str = Field(min_length=16)
database_url: str = "postgresql+psycopg://genexamenes:genexamenes@localhost:5432/genexamenes" database_url: str = "postgresql+psycopg://genexamenes:genexamenes@localhost:5432/genexamenes"
allowed_origins: str = "http://localhost:3000" allowed_origins: str = "http://localhost:5173,http://localhost:3000"
rate_limit_requests: int = Field(default=60, ge=1) rate_limit_requests: int = Field(default=60, ge=1)
rate_limit_window_seconds: int = Field(default=60, ge=1) rate_limit_window_seconds: int = Field(default=60, ge=1)
max_request_bytes: int = Field(default=1_048_576, ge=1_024) max_request_bytes: int = Field(default=1_048_576, ge=1_024)
llm_api_key: str | None = None llm_api_key: str | None = None
llm_base_url: str = "https://api.openai.com/v1" llm_base_url: str = ""
llm_model: str = "gpt-4o-mini" llm_model: str = "qwen3.5:35b"
llm_timeout_seconds: int = Field(default=60, ge=5) llm_timeout_seconds: int = Field(default=180, ge=5)
jwt_secret_key: str = Field(min_length=32) jwt_secret_key: str = Field(min_length=32)
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = Field(default=60 * 24, ge=5) jwt_expire_minutes: int = Field(default=60 * 24, ge=5)
+3
View File
@@ -17,6 +17,9 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self.requests: defaultdict[str, deque[float]] = defaultdict(deque) self.requests: defaultdict[str, deque[float]] = defaultdict(deque)
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if request.method == "OPTIONS":
return await call_next(request)
client = request.client.host if request.client else "unknown" client = request.client.host if request.client else "unknown"
now = time.monotonic() now = time.monotonic()
bucket = self.requests[client] bucket = self.requests[client]
+2
View File
@@ -1,7 +1,9 @@
from app.db.base import Base from app.db.base import Base
from app.db.migrations import run_migrations
from app.db.session import engine from app.db.session import engine
from app.models import exam, user # noqa: F401 from app.models import exam, user # noqa: F401
def init_db() -> None: def init_db() -> None:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
run_migrations(engine)
+104
View File
@@ -0,0 +1,104 @@
"""
Migraciones ligeras e idempotentes para bases de datos creadas antes de nuevas columnas.
Se ejecutan en cada arranque; solo aplican cambios que falten.
"""
from sqlalchemy import inspect, text
from sqlalchemy.engine import Engine
def run_migrations(engine: Engine) -> None:
inspector = inspect(engine)
table_names = set(inspector.get_table_names())
with engine.begin() as conn:
if "users" in table_names and "exam_templates" in table_names:
_ensure_exam_templates_user_id(conn, inspector)
if "questions" in table_names:
_ensure_questions_image_id(conn, inspector)
if "exam_materials" in table_names:
_ensure_material_status_enum(conn)
def _column_names(inspector, table: str) -> set[str]:
return {col["name"] for col in inspector.get_columns(table)}
def _ensure_exam_templates_user_id(conn, inspector) -> None:
if "user_id" in _column_names(inspector, "exam_templates"):
return
conn.execute(
text("ALTER TABLE exam_templates ADD COLUMN user_id UUID")
)
# Plantillas antiguas (sin usuario): asignar al primer usuario registrado.
conn.execute(
text(
"""
UPDATE exam_templates
SET user_id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1)
WHERE user_id IS NULL
AND EXISTS (SELECT 1 FROM users)
"""
)
)
# Si no hay usuarios o filas huérfanas, eliminar plantillas sin dueño.
conn.execute(text("DELETE FROM exam_templates WHERE user_id IS NULL"))
conn.execute(text("ALTER TABLE exam_templates ALTER COLUMN user_id SET NOT NULL"))
conn.execute(
text(
"""
ALTER TABLE exam_templates
ADD CONSTRAINT fk_exam_templates_user_id
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
"""
)
)
conn.execute(
text(
"CREATE INDEX IF NOT EXISTS ix_exam_templates_user_id ON exam_templates (user_id)"
)
)
def _ensure_questions_image_id(conn, inspector) -> None:
if "image_id" in _column_names(inspector, "questions"):
return
if "exam_images" not in set(inspector.get_table_names()):
return
conn.execute(text("ALTER TABLE questions ADD COLUMN image_id UUID"))
conn.execute(
text(
"""
ALTER TABLE questions
ADD CONSTRAINT fk_questions_image_id
FOREIGN KEY (image_id) REFERENCES exam_images(id) ON DELETE SET NULL
"""
)
)
conn.execute(
text("CREATE INDEX IF NOT EXISTS ix_questions_image_id ON questions (image_id)")
)
def _ensure_material_status_enum(conn) -> None:
# Asegura el tipo enum de PostgreSQL usado por exam_materials.status.
conn.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'materialstatus') THEN
CREATE TYPE materialstatus AS ENUM ('processed', 'failed');
END IF;
END
$$;
"""
)
)
+6 -3
View File
@@ -21,15 +21,18 @@ def create_app() -> FastAPI:
settings = get_settings() settings = get_settings()
app = FastAPI(title=settings.app_name, lifespan=lifespan) app = FastAPI(title=settings.app_name, lifespan=lifespan)
# CORS debe ser el middleware más externo (añadirlo al final) para que
# las peticiones OPTIONS (preflight) respondan antes que rate limit, etc.
app.add_middleware(RequestSizeLimitMiddleware, settings=settings)
app.add_middleware(RateLimitMiddleware, settings=settings)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins, allow_origins=settings.cors_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-API-Key"], allow_headers=["Authorization", "Content-Type", "X-API-Key", "Accept"],
expose_headers=["Content-Disposition"],
) )
app.add_middleware(RequestSizeLimitMiddleware, settings=settings)
app.add_middleware(RateLimitMiddleware, settings=settings)
register_exception_handlers(app) register_exception_handlers(app)
+12 -4
View File
@@ -4,7 +4,7 @@ from typing import Annotated
from fastapi import Depends from fastapi import Depends
from jose import JWTError, jwt from jose import JWTError, jwt
from passlib.context import CryptContext import bcrypt
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -18,7 +18,15 @@ from app.db.session import get_db
from app.models.user import User from app.models.user import User
from app.schemas.user import UserLogin, UserRead, UserRegister from app.schemas.user import UserLogin, UserRead, UserRegister
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") def _hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def _verify_password(password: str, password_hash: str) -> bool:
try:
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
except ValueError:
return False
class AuthService: class AuthService:
@@ -34,7 +42,7 @@ class AuthService:
user = User( user = User(
email=email, email=email,
password_hash=pwd_context.hash(payload.password), password_hash=_hash_password(payload.password),
full_name=clean_text(payload.full_name, max_length=200) if payload.full_name else None, full_name=clean_text(payload.full_name, max_length=200) if payload.full_name else None,
) )
self.db.add(user) self.db.add(user)
@@ -47,7 +55,7 @@ class AuthService:
user = self.db.scalar(select(User).where(User.email == email)) user = self.db.scalar(select(User).where(User.email == email))
if user is None or user.password_hash is None: if user is None or user.password_hash is None:
raise UnauthorizedError("Invalid email or password") raise UnauthorizedError("Invalid email or password")
if not pwd_context.verify(payload.password, user.password_hash): if not _verify_password(payload.password, user.password_hash):
raise UnauthorizedError("Invalid email or password") raise UnauthorizedError("Invalid email or password")
return user return user
+5
View File
@@ -93,6 +93,11 @@ class ExamService:
def get_template(self, user_id: uuid.UUID, template_id: uuid.UUID) -> ExamTemplateRead: def get_template(self, user_id: uuid.UUID, template_id: uuid.UUID) -> ExamTemplateRead:
return self._template_read(self._get_user_template_or_404(user_id, template_id)) return self._template_read(self._get_user_template_or_404(user_id, template_id))
def list_questions(self, user_id: uuid.UUID, template_id: uuid.UUID) -> list[QuestionRead]:
template = self._get_user_template_or_404(user_id, template_id)
questions = sorted(template.questions, key=lambda q: q.created_at)
return [self.to_question_read(question) for question in questions]
def get_owned_template(self, user_id: uuid.UUID, template_id: uuid.UUID) -> ExamTemplate: def get_owned_template(self, user_id: uuid.UUID, template_id: uuid.UUID) -> ExamTemplate:
return self._get_user_template_or_404(user_id, template_id) return self._get_user_template_or_404(user_id, template_id)
+46 -21
View File
@@ -5,44 +5,69 @@ from app.core.errors import LLMUnavailableError
class LLMClient: class LLMClient:
"""Cliente para el API de chat de Sinbad2IA (Ollama-compatible en UJA)."""
def __init__(self, settings: Settings) -> None: def __init__(self, settings: Settings) -> None:
self.settings = settings self.settings = settings
async def generate(self, prompt: str) -> str: def _chat_url(self) -> str:
if not self.settings.llm_api_key: base = self.settings.llm_base_url.rstrip("/")
raise LLMUnavailableError("LLM_API_KEY is not configured") if base.endswith("/api/chat"):
return base
return f"{base}/api/chat"
url = f"{self.settings.llm_base_url.rstrip('/')}/chat/completions" async def generate(self, prompt: str) -> str:
payload = { payload = {
"model": self.settings.llm_model, "model": self.settings.llm_model,
"messages": [ "messages": [
{ {
"role": "system", "role": "user",
"content": "You generate safe, valid JSON exam questions for Moodle imports.", "content": (
"Genera preguntas de examen en formato JSON válido para importar en Moodle. "
"Responde únicamente con el JSON solicitado, sin texto adicional ni bloques markdown.\n\n"
f"{prompt}"
),
}, },
{"role": "user", "content": prompt},
], ],
"temperature": 0.2, "stream": False,
"response_format": {"type": "json_object"},
}
headers = {
"Authorization": f"Bearer {self.settings.llm_api_key}",
"Content-Type": "application/json",
} }
headers = {"Content-Type": "application/json"}
if self.settings.llm_api_key:
headers["Authorization"] = f"Bearer {self.settings.llm_api_key}"
try: try:
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_seconds) as client: async with httpx.AsyncClient(timeout=self.settings.llm_timeout_seconds) as client:
response = await client.post(url, json=payload, headers=headers) response = await client.post(self._chat_url(), json=payload, headers=headers)
response.raise_for_status() response.raise_for_status()
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
raise LLMUnavailableError("LLM request failed") from exc raise LLMUnavailableError("LLM request failed") from exc
data = response.json() content = _extract_assistant_content(response.json())
try: if not content.strip():
content = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise LLMUnavailableError("LLM response did not include message content") from exc
if not isinstance(content, str) or not content.strip():
raise LLMUnavailableError("LLM returned empty content") raise LLMUnavailableError("LLM returned empty content")
return content return content
def _extract_assistant_content(data: object) -> str:
"""Soporta respuesta Sinbad2IA/Ollama (`message.content`) y OpenAI (`choices`)."""
if not isinstance(data, dict):
raise LLMUnavailableError("LLM response is not a JSON object")
message = data.get("message")
if isinstance(message, dict):
content = message.get("content")
if isinstance(content, str) and content.strip():
return content
choices = data.get("choices")
if isinstance(choices, list) and choices:
first = choices[0]
if isinstance(first, dict):
msg = first.get("message")
if isinstance(msg, dict):
content = msg.get("content")
if isinstance(content, str) and content.strip():
return content
raise LLMUnavailableError("LLM response did not include message content")
+1 -1
View File
@@ -7,7 +7,7 @@ pydantic[email]
python-dotenv python-dotenv
httpx httpx
orjson orjson
passlib[bcrypt] bcrypt>=4.0.1,<5
python-jose[cryptography] python-jose[cryptography]
google-auth google-auth
requests requests
+13 -4
View File
@@ -6,6 +6,11 @@ services:
- ./backend/.env - ./backend/.env
environment: environment:
DATABASE_URL: postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes DATABASE_URL: postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
# Sobrescribe backend/.env si aún tiene el puerto 3000 del frontend antiguo.
ALLOWED_ORIGINS: http://localhost:5173,http://localhost:3000
LLM_BASE_URL:
LLM_MODEL: qwen3.5:35b
LLM_TIMEOUT_SECONDS: "180"
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on:
@@ -16,11 +21,15 @@ services:
restart: unless-stopped restart: unless-stopped
frontend: frontend:
image: nginx:1.27-alpine build:
context: ./frontend
args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:8000}
VITE_GOOGLE_CLIENT_ID: ${VITE_GOOGLE_CLIENT_ID:-}
ports: ports:
- "3000:80" - "5173:80"
volumes: depends_on:
- ./frontend:/usr/share/nginx/html:ro - backend
restart: unless-stopped restart: unless-stopped
db: db:
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.env
.env.local
*.log
.git
+6
View File
@@ -0,0 +1,6 @@
# URL base del backend (accesible desde el navegador)
VITE_API_URL=http://localhost:8000
# (Opcional) Client ID de Google para "Iniciar sesión con Google".
# Debe coincidir con GOOGLE_CLIENT_ID del backend.
VITE_GOOGLE_CLIENT_ID=
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.env
.env.local
*.log
.DS_Store
+20
View File
@@ -0,0 +1,20 @@
# --- Build stage ---
FROM node:20-alpine AS build
WORKDIR /app
ARG VITE_API_URL=http://localhost:8000
ARG VITE_GOOGLE_CLIENT_ID=
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
# --- Serve stage ---
FROM nginx:1.27-alpine AS serve
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+71
View File
@@ -0,0 +1,71 @@
# GenExámenes IA — Frontend
Interfaz web en **React + Vite** para el generador de exámenes con IA. Consume
la API del backend (FastAPI) y cubre todo el flujo: autenticación, plantillas,
material de contexto, imágenes, generación con IA, gestión de preguntas y
exportación a Moodle.
## Stack
- **React 18** + **React Router 6**
- **Vite 5** (build y dev server)
- **Axios** con interceptores para el token JWT y el manejo unificado de errores HTTP
## Estructura
```
src/
api/ Cliente axios y un módulo por recurso (auth, templates, materials, images, ...)
components/ Componentes reutilizables (UI, layout, AuthImage, QuestionCard, ...)
context/ AuthContext (sesión) y ToastContext (notificaciones)
hooks/ useGoogleSignIn (login con Google opcional)
pages/ Páginas y pestañas del detalle de plantilla
utils/ Constantes y formateadores
```
## Desarrollo local
Requisitos: Node 20+ y el backend corriendo en `http://localhost:8000`.
```bash
cd frontend
cp .env.example .env # ajusta VITE_API_URL si es necesario
npm install
npm run dev # http://localhost:5173
```
## Variables de entorno
| Variable | Descripción |
| ----------------------- | -------------------------------------------------------- |
| `VITE_API_URL` | URL base del backend (por defecto `http://localhost:8000`). |
| `VITE_GOOGLE_CLIENT_ID` | (Opcional) Client ID de Google. Si está vacío, se oculta el botón de Google. |
> Las variables `VITE_*` se incrustan en el build, por lo que apuntan al backend
> tal y como lo verá el navegador del usuario.
## Build de producción
```bash
npm run build # genera dist/
npm run preview # sirve el build localmente
```
## Docker
El `docker-compose.yml` de la raíz construye el frontend con un build multi-stage
(Node → Nginx) y lo publica en `http://localhost:5173`:
```bash
docker compose up --build
```
Las variables `VITE_API_URL` y `VITE_GOOGLE_CLIENT_ID` pueden pasarse como
variables de entorno al ejecutar `docker compose`.
## Manejo de errores
Todas las respuestas de error del backend siguen el formato
`{ "error": { "code", "message", "details" } }`. El interceptor de Axios las
normaliza a una `ApiError` y la UI las muestra mediante *toasts*. Un `401`
global cierra la sesión automáticamente.
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="GenExámenes IA — Generador de exámenes con IA y exportación a Moodle." />
<title>GenExámenes IA</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA: cualquier ruta desconocida sirve index.html (React Router).
location / {
try_files $uri $uri/ /index.html;
}
# Cache de assets con hash.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
+2029
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "genexamenes-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.7",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.2",
"vite": "^5.4.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#6366f1"/>
<path d="M9 8h11l3 3v13a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z" fill="#fff"/>
<path d="M19 8v3a1 1 0 0 0 1 1h3" fill="#c7d2fe"/>
<path d="M11 16h10M11 19.5h10M11 23h6" stroke="#6366f1" stroke-width="1.6" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

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