diff --git a/README.md b/README.md
index 1971961..9bfc74d 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ El proyecto está centrado en backend. La carpeta `frontend` se mantiene vacía
- FastAPI
- PostgreSQL
- 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`
## Puesta en Marcha
@@ -44,9 +44,10 @@ Variables principales:
- `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`).
- `DATABASE_URL`: conexión PostgreSQL usada por el backend.
-- `LLM_API_KEY`: clave del proveedor LLM.
-- `LLM_BASE_URL`: endpoint compatible con OpenAI.
-- `LLM_MODEL`: modelo usado para generar preguntas.
+- `LLM_BASE_URL`: URL base del servidor (por defecto ``; el cliente usa `/api/chat`).
+- `LLM_MODEL`: modelo (por defecto `qwen3.5:35b`).
+- `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.
- `MAX_STORAGE_BYTES_PER_TEMPLATE`: cupo total de almacenamiento por examen (materiales + imágenes).
diff --git a/backend/.env.example b/backend/.env.example
index b098eb6..6625d88 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -9,7 +9,7 @@ API_KEY=change-me-in-production-min-16-chars
DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
# --- 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_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.
GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com
-# --- LLM (OpenAI o compatible) ---
-LLM_API_KEY=sk-your-openai-api-key
-LLM_BASE_URL=https://api.openai.com/v1
-LLM_MODEL=gpt-4o-mini
-LLM_TIMEOUT_SECONDS=60
+# --- LLM (Sinbad2IA UJA — sin clave) ---
+# URL base del servidor; el cliente llama a {LLM_BASE_URL}/api/chat
+LLM_BASE_URL=
+LLM_MODEL=qwen3.5:35b
+LLM_TIMEOUT_SECONDS=180
+# Opcional, solo si el servidor exige autenticación:
+# LLM_API_KEY=
diff --git a/backend/app/api/routes/templates.py b/backend/app/api/routes/templates.py
index 5b9a1ff..bf63366 100644
--- a/backend/app/api/routes/templates.py
+++ b/backend/app/api/routes/templates.py
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, status
from app.api.dependencies import get_exam_service, get_storage_quota_service
from app.core.auth import get_current_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.services.exam_service import ExamService
from app.services.storage_quota import StorageQuotaService
@@ -40,6 +40,15 @@ def get_template(
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)
def get_template_storage_usage(
template_id: uuid.UUID,
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index a2e47e0..8e60ac1 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -10,14 +10,14 @@ class Settings(BaseSettings):
api_prefix: str = ""
api_key: str = Field(min_length=16)
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_window_seconds: int = Field(default=60, ge=1)
max_request_bytes: int = Field(default=1_048_576, ge=1_024)
llm_api_key: str | None = None
- llm_base_url: str = "https://api.openai.com/v1"
- llm_model: str = "gpt-4o-mini"
- llm_timeout_seconds: int = Field(default=60, ge=5)
+ llm_base_url: str = ""
+ llm_model: str = "qwen3.5:35b"
+ llm_timeout_seconds: int = Field(default=180, ge=5)
jwt_secret_key: str = Field(min_length=32)
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = Field(default=60 * 24, ge=5)
diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py
index 8630ac3..af0e826 100644
--- a/backend/app/core/middleware.py
+++ b/backend/app/core/middleware.py
@@ -17,6 +17,9 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
self.requests: defaultdict[str, deque[float]] = defaultdict(deque)
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"
now = time.monotonic()
bucket = self.requests[client]
diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py
index da37a46..5d5c06b 100644
--- a/backend/app/db/init_db.py
+++ b/backend/app/db/init_db.py
@@ -1,7 +1,9 @@
from app.db.base import Base
+from app.db.migrations import run_migrations
from app.db.session import engine
from app.models import exam, user # noqa: F401
def init_db() -> None:
Base.metadata.create_all(bind=engine)
+ run_migrations(engine)
diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py
new file mode 100644
index 0000000..0096ac4
--- /dev/null
+++ b/backend/app/db/migrations.py
@@ -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
+ $$;
+ """
+ )
+ )
diff --git a/backend/app/main.py b/backend/app/main.py
index 7157853..c1e55bc 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -21,15 +21,18 @@ def create_app() -> FastAPI:
settings = get_settings()
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(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
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)
diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py
index 0f509b5..1441a35 100644
--- a/backend/app/services/auth_service.py
+++ b/backend/app/services/auth_service.py
@@ -4,7 +4,7 @@ from typing import Annotated
from fastapi import Depends
from jose import JWTError, jwt
-from passlib.context import CryptContext
+import bcrypt
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -18,7 +18,15 @@ from app.db.session import get_db
from app.models.user import User
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:
@@ -34,7 +42,7 @@ class AuthService:
user = User(
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,
)
self.db.add(user)
@@ -47,7 +55,7 @@ class AuthService:
user = self.db.scalar(select(User).where(User.email == email))
if user is None or user.password_hash is None:
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")
return user
diff --git a/backend/app/services/exam_service.py b/backend/app/services/exam_service.py
index a4371b2..19097df 100644
--- a/backend/app/services/exam_service.py
+++ b/backend/app/services/exam_service.py
@@ -93,6 +93,11 @@ class ExamService:
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))
+ 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:
return self._get_user_template_or_404(user_id, template_id)
diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py
index 0356cb4..01b5cd5 100644
--- a/backend/app/services/llm.py
+++ b/backend/app/services/llm.py
@@ -5,44 +5,69 @@ from app.core.errors import LLMUnavailableError
class LLMClient:
+ """Cliente para el API de chat de Sinbad2IA (Ollama-compatible en UJA)."""
+
def __init__(self, settings: Settings) -> None:
self.settings = settings
- async def generate(self, prompt: str) -> str:
- if not self.settings.llm_api_key:
- raise LLMUnavailableError("LLM_API_KEY is not configured")
+ def _chat_url(self) -> str:
+ base = self.settings.llm_base_url.rstrip("/")
+ 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 = {
"model": self.settings.llm_model,
"messages": [
{
- "role": "system",
- "content": "You generate safe, valid JSON exam questions for Moodle imports.",
+ "role": "user",
+ "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,
- "response_format": {"type": "json_object"},
- }
- headers = {
- "Authorization": f"Bearer {self.settings.llm_api_key}",
- "Content-Type": "application/json",
+ "stream": False,
}
+ headers = {"Content-Type": "application/json"}
+ if self.settings.llm_api_key:
+ headers["Authorization"] = f"Bearer {self.settings.llm_api_key}"
+
try:
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()
except httpx.HTTPError as exc:
raise LLMUnavailableError("LLM request failed") from exc
- data = response.json()
- try:
- 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():
+ content = _extract_assistant_content(response.json())
+ if not content.strip():
raise LLMUnavailableError("LLM returned empty 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")
diff --git a/backend/requirements.txt b/backend/requirements.txt
index f391448..82b0a2c 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -7,7 +7,7 @@ pydantic[email]
python-dotenv
httpx
orjson
-passlib[bcrypt]
+bcrypt>=4.0.1,<5
python-jose[cryptography]
google-auth
requests
diff --git a/docker-compose.yml b/docker-compose.yml
index 11f8de1..d7751a4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,6 +6,11 @@ services:
- ./backend/.env
environment:
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:
- "8000:8000"
depends_on:
@@ -16,11 +21,15 @@ services:
restart: unless-stopped
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:
- - "3000:80"
- volumes:
- - ./frontend:/usr/share/nginx/html:ro
+ - "5173:80"
+ depends_on:
+ - backend
restart: unless-stopped
db:
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..c071f14
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,6 @@
+node_modules
+dist
+.env
+.env.local
+*.log
+.git
diff --git a/frontend/.env.example b/frontend/.env.example
new file mode 100644
index 0000000..d293dcc
--- /dev/null
+++ b/frontend/.env.example
@@ -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=
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..94362eb
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+dist/
+.env
+.env.local
+*.log
+.DS_Store
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..8479044
--- /dev/null
+++ b/frontend/Dockerfile
@@ -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;"]
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..3e04f27
--- /dev/null
+++ b/frontend/README.md
@@ -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.
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..615b351
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ GenExámenes IA
+
+
+
+
+
+
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..23a100c
--- /dev/null
+++ b/frontend/nginx.conf
@@ -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;
+}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..df4991e
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,2029 @@
+{
+ "name": "genexamenes-frontend",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "genexamenes-frontend",
+ "version": "1.0.0",
+ "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"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.3",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
+ "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.0.tgz",
+ "integrity": "sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.0.tgz",
+ "integrity": "sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.0.tgz",
+ "integrity": "sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.0.tgz",
+ "integrity": "sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.0.tgz",
+ "integrity": "sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.0.tgz",
+ "integrity": "sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.0.tgz",
+ "integrity": "sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.0.tgz",
+ "integrity": "sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.0.tgz",
+ "integrity": "sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.0.tgz",
+ "integrity": "sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.0.tgz",
+ "integrity": "sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.0.tgz",
+ "integrity": "sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.0.tgz",
+ "integrity": "sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.0.tgz",
+ "integrity": "sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.0.tgz",
+ "integrity": "sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.0.tgz",
+ "integrity": "sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.0.tgz",
+ "integrity": "sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.0.tgz",
+ "integrity": "sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.0.tgz",
+ "integrity": "sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.0.tgz",
+ "integrity": "sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.0.tgz",
+ "integrity": "sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.0.tgz",
+ "integrity": "sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.0.tgz",
+ "integrity": "sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.0.tgz",
+ "integrity": "sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.0.tgz",
+ "integrity": "sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/axios": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
+ "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.5",
+ "https-proxy-agent": "^5.0.1",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.33",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
+ "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001793",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
+ "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.364",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz",
+ "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.46",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
+ "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "6.30.4",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
+ "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.3"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "6.30.4",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
+ "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@remix-run/router": "1.23.3",
+ "react-router": "6.30.4"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.61.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.0.tgz",
+ "integrity": "sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.61.0",
+ "@rollup/rollup-android-arm64": "4.61.0",
+ "@rollup/rollup-darwin-arm64": "4.61.0",
+ "@rollup/rollup-darwin-x64": "4.61.0",
+ "@rollup/rollup-freebsd-arm64": "4.61.0",
+ "@rollup/rollup-freebsd-x64": "4.61.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.61.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.61.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.61.0",
+ "@rollup/rollup-linux-arm64-musl": "4.61.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.61.0",
+ "@rollup/rollup-linux-loong64-musl": "4.61.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.61.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.61.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.61.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.61.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.61.0",
+ "@rollup/rollup-linux-x64-gnu": "4.61.0",
+ "@rollup/rollup-linux-x64-musl": "4.61.0",
+ "@rollup/rollup-openbsd-x64": "4.61.0",
+ "@rollup/rollup-openharmony-arm64": "4.61.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.61.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.61.0",
+ "@rollup/rollup-win32-x64-gnu": "4.61.0",
+ "@rollup/rollup-win32-x64-msvc": "4.61.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..dd61432
--- /dev/null
+++ b/frontend/package.json
@@ -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"
+ }
+}
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
new file mode 100644
index 0000000..35c2109
--- /dev/null
+++ b/frontend/public/favicon.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..0b84c72
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -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 (
+
+ } />
+ } />
+
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+
+
+ } />
+ } />
+
+ );
+}
diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js
new file mode 100644
index 0000000..4c25191
--- /dev/null
+++ b/frontend/src/api/auth.js
@@ -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 }
+}
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
new file mode 100644
index 0000000..d1481a7
--- /dev/null
+++ b/frontend/src/api/client.js
@@ -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];
+}
diff --git a/frontend/src/api/exports.js b/frontend/src/api/exports.js
new file mode 100644
index 0000000..2a3f601
--- /dev/null
+++ b/frontend/src/api/exports.js
@@ -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;
diff --git a/frontend/src/api/generation.js b/frontend/src/api/generation.js
new file mode 100644
index 0000000..9cfc7fb
--- /dev/null
+++ b/frontend/src/api/generation.js
@@ -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: [...] }
+}
diff --git a/frontend/src/api/images.js b/frontend/src/api/images.js
new file mode 100644
index 0000000..38e34d2
--- /dev/null
+++ b/frontend/src/api/images.js
@@ -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 .
+ */
+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 };
diff --git a/frontend/src/api/materials.js b/frontend/src/api/materials.js
new file mode 100644
index 0000000..584d9d4
--- /dev/null
+++ b/frontend/src/api/materials.js
@@ -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}`);
+}
diff --git a/frontend/src/api/questions.js b/frontend/src/api/questions.js
new file mode 100644
index 0000000..3e06937
--- /dev/null
+++ b/frontend/src/api/questions.js
@@ -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
+}
diff --git a/frontend/src/api/templates.js b/frontend/src/api/templates.js
new file mode 100644
index 0000000..676b596
--- /dev/null
+++ b/frontend/src/api/templates.js
@@ -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;
+}
diff --git a/frontend/src/components/AuthImage.jsx b/frontend/src/components/AuthImage.jsx
new file mode 100644
index 0000000..3c09cea
--- /dev/null
+++ b/frontend/src/components/AuthImage.jsx
@@ -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 (
+
+ No disponible
+
+ );
+ }
+ if (!url) {
+ return (
+
+
+
+ );
+ }
+ return ;
+}
diff --git a/frontend/src/components/AuthLayout.jsx b/frontend/src/components/AuthLayout.jsx
new file mode 100644
index 0000000..cc1841f
--- /dev/null
+++ b/frontend/src/components/AuthLayout.jsx
@@ -0,0 +1,54 @@
+import Icon from "./ui/Icon";
+
+export default function AuthLayout({ children }) {
+ return (
+
+
+
+
+
+
+ GenExámenes IA
+
+
+
Crea exámenes con IA y expórtalos a Moodle en minutos.
+
+ Define la plantilla, sube tu material de estudio y deja que la IA
+ redacte las preguntas por ti.
+
+
+ Preguntas tipo test, V/F, respuesta corta y emparejamiento.
+
+
+ Sube PDF, DOCX, TXT o imágenes y la IA usará su contenido.
+
+
+ Descarga el examen en XML compatible con Moodle, TXT o JSON.
+
+
+
+ © {new Date().getFullYear()} GenExámenes IA
+
+
+
+ {children}
+
+
+ );
+}
+
+function Feature({ icon, title, children }) {
+ return (
+
+
+
+
+
+
{title}
+
+ {children}
+
+
+
+ );
+}
diff --git a/frontend/src/components/FileDropzone.jsx b/frontend/src/components/FileDropzone.jsx
new file mode 100644
index 0000000..5d00251
--- /dev/null
+++ b/frontend/src/components/FileDropzone.jsx
@@ -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 (
+ inputRef.current?.click()}
+ onDragOver={(e) => {
+ e.preventDefault();
+ setDrag(true);
+ }}
+ onDragLeave={() => setDrag(false)}
+ onDrop={(e) => {
+ e.preventDefault();
+ setDrag(false);
+ handleFiles(e.dataTransfer.files);
+ }}
+ >
+
+
+
+
+ Arrastra un archivo o haz clic para seleccionar
+
+ {hint &&
{hint}
}
+
{
+ handleFiles(e.target.files);
+ e.target.value = "";
+ }}
+ />
+
+ );
+}
diff --git a/frontend/src/components/QuestionCard.jsx b/frontend/src/components/QuestionCard.jsx
new file mode 100644
index 0000000..d768173
--- /dev/null
+++ b/frontend/src/components/QuestionCard.jsx
@@ -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 (
+
+
+
+ #{index}
+ {QUESTION_TYPE_LABEL[question.question_type] || question.question_type}
+ {diff && {diff.label} }
+
+ {question.score} pt{question.penalty ? ` · -${question.penalty}` : ""}
+
+
+ {question.image_id && (
+
+
+ Con imagen
+
+ )}
+
+
+
{question.statement}
+
+ {question.image_id && (
+
+ )}
+
+ {question.question_type === "matching" ? (
+
+ {(question.matching_pairs || []).map((p, i) => (
+
+ {p.prompt}
+ ↔
+ {p.answer}
+
+ ))}
+
+ ) : (
+
+ {(question.correct_answers || []).map((a, i) => (
+
+
+ {a}
+
+ ))}
+ {(question.wrong_answers || []).map((a, i) => (
+
+
+ {a}
+
+ ))}
+
+ )}
+
+ {footer &&
{footer}
}
+
+ );
+}
diff --git a/frontend/src/components/StorageBar.jsx b/frontend/src/components/StorageBar.jsx
new file mode 100644
index 0000000..63d7346
--- /dev/null
+++ b/frontend/src/components/StorageBar.jsx
@@ -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 (
+
+
+
+ Almacenamiento del examen ({formatBytes(storage.used_bytes)} /{" "}
+ {formatBytes(storage.limit_bytes)})
+
+ {pct}%
+
+
+
+
+
+ Materiales: {formatBytes(storage.materials_bytes)}
+
+
+
+ Imágenes: {formatBytes(storage.images_bytes)}
+
+ Disponible: {formatBytes(storage.remaining_bytes)}
+
+
+ );
+}
diff --git a/frontend/src/components/layout/Layout.jsx b/frontend/src/components/layout/Layout.jsx
new file mode 100644
index 0000000..4e51cad
--- /dev/null
+++ b/frontend/src/components/layout/Layout.jsx
@@ -0,0 +1,11 @@
+import { Outlet } from "react-router-dom";
+import Navbar from "./Navbar";
+
+export default function Layout() {
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/layout/Navbar.jsx b/frontend/src/components/layout/Navbar.jsx
new file mode 100644
index 0000000..7e64384
--- /dev/null
+++ b/frontend/src/components/layout/Navbar.jsx
@@ -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 (
+
+ );
+}
diff --git a/frontend/src/components/layout/ProtectedRoute.jsx b/frontend/src/components/layout/ProtectedRoute.jsx
new file mode 100644
index 0000000..fc9b81a
--- /dev/null
+++ b/frontend/src/components/layout/ProtectedRoute.jsx
@@ -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 ;
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ return children;
+}
diff --git a/frontend/src/components/ui/Button.jsx b/frontend/src/components/ui/Button.jsx
new file mode 100644
index 0000000..5ed037e
--- /dev/null
+++ b/frontend/src/components/ui/Button.jsx
@@ -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 (
+
+ {loading && }
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/ui/Field.jsx b/frontend/src/components/ui/Field.jsx
new file mode 100644
index 0000000..7126c88
--- /dev/null
+++ b/frontend/src/components/ui/Field.jsx
@@ -0,0 +1,54 @@
+export function Field({ label, hint, error, children, htmlFor }) {
+ return (
+
+ {label && (
+
+ {label}
+
+ )}
+ {children}
+ {error ? (
+
{error}
+ ) : hint ? (
+
{hint}
+ ) : null}
+
+ );
+}
+
+export function Input({ error, className = "", ...props }) {
+ return (
+
+ );
+}
+
+export function Textarea({ error, mono, className = "", ...props }) {
+ return (
+
+ );
+}
+
+export function Select({ className = "", children, ...props }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function Checkbox({ label, checked, onChange, ...props }) {
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/frontend/src/components/ui/Icon.jsx b/frontend/src/components/ui/Icon.jsx
new file mode 100644
index 0000000..4aad08c
--- /dev/null
+++ b/frontend/src/components/ui/Icon.jsx
@@ -0,0 +1,178 @@
+const PATHS = {
+ document: (
+ <>
+
+
+
+ >
+ ),
+ book: (
+ <>
+
+
+ >
+ ),
+ image: (
+ <>
+
+
+
+ >
+ ),
+ sparkles: (
+ <>
+
+
+ >
+ ),
+ help: <> >,
+ upload: (
+ <>
+
+
+ >
+ ),
+ clipboard: (
+ <>
+
+
+ >
+ ),
+ lock: (
+ <>
+
+
+ >
+ ),
+ folder: (
+ <>
+
+ >
+ ),
+ clock: (
+ <>
+
+
+ >
+ ),
+ inbox: (
+ <>
+
+
+ >
+ ),
+ compass: (
+ <>
+
+
+ >
+ ),
+ file: (
+ <>
+
+
+ >
+ ),
+ graduation: (
+ <>
+
+
+ >
+ ),
+ globe: (
+ <>
+
+
+ >
+ ),
+ cpu: (
+ <>
+
+
+ >
+ ),
+ paperclip: (
+
+ ),
+ close: (
+ <>
+
+ >
+ ),
+ check: ,
+ x: (
+ <>
+
+ >
+ ),
+ info: (
+ <>
+
+
+ >
+ ),
+ ban: (
+ <>
+
+
+ >
+ ),
+ listChecks: (
+ <>
+
+
+ >
+ ),
+ toggle: ,
+ pencil: (
+ <>
+
+
+ >
+ ),
+ link: (
+ <>
+
+
+ >
+ ),
+ download: (
+ <>
+
+
+ >
+ ),
+ moodle: (
+ <>
+
+
+ >
+ ),
+ plus: (
+ <>
+
+ >
+ ),
+};
+
+export default function Icon({ name, size = 18, className = "", strokeWidth = 1.75 }) {
+ const content = PATHS[name];
+ if (!content) return null;
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/frontend/src/components/ui/Misc.jsx b/frontend/src/components/ui/Misc.jsx
new file mode 100644
index 0000000..9ad31de
--- /dev/null
+++ b/frontend/src/components/ui/Misc.jsx
@@ -0,0 +1,26 @@
+import Icon from "./Icon";
+
+export function Badge({ variant, children }) {
+ return {children} ;
+}
+
+export function EmptyState({ icon = "inbox", title, message, action }) {
+ return (
+
+
+
+
+
{title}
+ {message &&
{message}
}
+ {action &&
{action}
}
+
+ );
+}
+
+export function Card({ children, className = "", ...props }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/src/components/ui/Modal.jsx b/frontend/src/components/ui/Modal.jsx
new file mode 100644
index 0000000..462a4b5
--- /dev/null
+++ b/frontend/src/components/ui/Modal.jsx
@@ -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(
+
+
e.stopPropagation()}
+ >
+ {title && (
+
+
{title}
+
+
+
+
+
+ )}
+
{children}
+ {footer &&
{footer}
}
+
+
,
+ document.body
+ );
+}
+
+export function ConfirmDialog({
+ open,
+ title = "¿Confirmar?",
+ message,
+ confirmLabel = "Confirmar",
+ danger,
+ loading,
+ onConfirm,
+ onClose,
+}) {
+ return (
+
+
+ Cancelar
+
+
+ {confirmLabel}
+
+ >
+ }
+ >
+
+ {message}
+
+
+ );
+}
diff --git a/frontend/src/components/ui/Spinner.jsx b/frontend/src/components/ui/Spinner.jsx
new file mode 100644
index 0000000..b9dc3cb
--- /dev/null
+++ b/frontend/src/components/ui/Spinner.jsx
@@ -0,0 +1,21 @@
+export default function Spinner({ large, light }) {
+ const classes = [
+ "spinner",
+ large ? "spinner-lg" : "",
+ light ? "spinner-light" : "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+ return ;
+}
+
+export function SpinnerCenter({ label }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx
new file mode 100644
index 0000000..998289e
--- /dev/null
+++ b/frontend/src/context/AuthContext.jsx
@@ -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 {children} ;
+}
+
+export function useAuth() {
+ const ctx = useContext(AuthContext);
+ if (!ctx) throw new Error("useAuth debe usarse dentro de AuthProvider");
+ return ctx;
+}
diff --git a/frontend/src/context/ToastContext.jsx b/frontend/src/context/ToastContext.jsx
new file mode 100644
index 0000000..c893530
--- /dev/null
+++ b/frontend/src/context/ToastContext.jsx
@@ -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 (
+
+ {children}
+
+ {toasts.map((t) => (
+
+
+
+
+
+
{t.title}
+ {t.msg &&
{t.msg}
}
+
+
remove(t.id)}
+ >
+
+
+
+ ))}
+
+
+ );
+}
+
+export function useToast() {
+ const ctx = useContext(ToastContext);
+ if (!ctx) throw new Error("useToast debe usarse dentro de ToastProvider");
+ return ctx;
+}
diff --git a/frontend/src/hooks/useGoogleSignIn.js b/frontend/src/hooks/useGoogleSignIn.js
new file mode 100644
index 0000000..17198b8
--- /dev/null
+++ b/frontend/src/hooks/useGoogleSignIn.js
@@ -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 };
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..1c5e016
--- /dev/null
+++ b/frontend/src/index.css
@@ -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);
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..01a6221
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -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(
+
+
+
+
+
+
+
+
+
+);
diff --git a/frontend/src/pages/CreateTemplatePage.jsx b/frontend/src/pages/CreateTemplatePage.jsx
new file mode 100644
index 0000000..198e53a
--- /dev/null
+++ b/frontend/src/pages/CreateTemplatePage.jsx
@@ -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 (
+
+
+
+
Nuevo examen
+
Define la estructura. Después podrás subir material y generar preguntas.
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
new file mode 100644
index 0000000..303f118
--- /dev/null
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -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 ;
+
+ return (
+
+
+
+
Mis exámenes
+
Gestiona tus plantillas de examen y genera preguntas con IA.
+
+
+ navigate("/plantillas/nueva")}>
+
+ Nuevo examen
+
+
+
+
+ {templates.length === 0 ? (
+
+ navigate("/plantillas/nueva")}>
+ Crear mi primer examen
+
+ }
+ />
+
+ ) : (
+
+ {templates.map((t) => (
+
navigate(`/plantillas/${t.id}`)}
+ >
+
+
+
+ {t.subject}
+ 0 ? "success" : undefined}>
+ {t.question_count} preg.
+
+
+
{t.title}
+
+
+
+ {t.educational_level}
+
+
+
+ {t.language?.toUpperCase()}
+
+
+
+
+ {formatLastUpdated(t.updated_at)}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx
new file mode 100644
index 0000000..a3a5bf9
--- /dev/null
+++ b/frontend/src/pages/LoginPage.jsx
@@ -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 (
+
+ Bienvenido de nuevo
+ Inicia sesión para gestionar tus exámenes.
+
+
+
+ {googleEnabled && (
+ <>
+ o continúa con
+
+ >
+ )}
+
+
+ ¿No tienes cuenta? Crea una gratis
+
+
+ );
+}
diff --git a/frontend/src/pages/NotFoundPage.jsx b/frontend/src/pages/NotFoundPage.jsx
new file mode 100644
index 0000000..d872a2d
--- /dev/null
+++ b/frontend/src/pages/NotFoundPage.jsx
@@ -0,0 +1,19 @@
+import { Link } from "react-router-dom";
+import Icon from "../components/ui/Icon";
+
+export default function NotFoundPage() {
+ return (
+
+
+
+
+
Página no encontrada
+
+ La página que buscas no existe o ha sido movida.
+
+
+ Volver al inicio
+
+
+ );
+}
diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx
new file mode 100644
index 0000000..009194a
--- /dev/null
+++ b/frontend/src/pages/RegisterPage.jsx
@@ -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 (
+
+ Crea tu cuenta
+ Empieza a generar exámenes en segundos.
+
+
+
+ {googleEnabled && (
+ <>
+ o regístrate con
+
+ >
+ )}
+
+
+ ¿Ya tienes cuenta? Inicia sesión
+
+
+ );
+}
diff --git a/frontend/src/pages/TemplateDetailPage.jsx b/frontend/src/pages/TemplateDetailPage.jsx
new file mode 100644
index 0000000..c0d992a
--- /dev/null
+++ b/frontend/src/pages/TemplateDetailPage.jsx
@@ -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 ;
+
+ if (notFound) {
+ return (
+
+
+
+
+
Examen no disponible
+
+ No existe o no tienes permiso para verlo.
+
+
+ Volver a mis exámenes
+
+
+ );
+ }
+
+ const shared = {
+ templateId,
+ template,
+ storage,
+ materials,
+ images,
+ questions,
+ reloadStorage,
+ reloadMaterials,
+ reloadImages,
+ reloadQuestions,
+ reloadTemplate,
+ goToTab: setTab,
+ };
+
+ return (
+
+
+ Mis exámenes / {template.title}
+
+
+
+
{template.title}
+
+ {template.subject}
+
+
+ {template.educational_level}
+
+
+
+ {template.language?.toUpperCase()}
+
+ 0 ? "success" : undefined}>
+ {questions.length} preguntas
+
+
+
+
+
+
+ {TABS.map((t) => (
+ setTab(t.id)}
+ >
+
+ {t.label}
+ {t.id === "materials" && materials.length > 0 && (
+ {materials.length}
+ )}
+ {t.id === "images" && images.length > 0 && (
+ {images.length}
+ )}
+ {t.id === "questions" && questions.length > 0 && (
+ {questions.length}
+ )}
+
+ ))}
+
+
+ {tab === "overview" &&
}
+ {tab === "materials" &&
}
+ {tab === "images" &&
}
+ {tab === "generate" &&
}
+ {tab === "questions" &&
}
+ {tab === "export" &&
}
+
+ );
+}
diff --git a/frontend/src/pages/template/ExportTab.jsx b/frontend/src/pages/template/ExportTab.jsx
new file mode 100644
index 0000000..1aa1028
--- /dev/null
+++ b/frontend/src/pages/template/ExportTab.jsx
@@ -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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+ Tu examen tiene {questions.length} preguntas . Elige un
+ formato para descargarlo o previsualizarlo.
+
+
+ {FORMATS.map((fmt) => (
+
+
+
+
+
{fmt.title}
+
+ {fmt.desc}
+
+
+ run(fmt, { download: true })}
+ loading={loadingFormat === fmt.id}
+ >
+ Descargar
+
+ run(fmt, { download: false })}
+ disabled={loadingFormat === fmt.id}
+ >
+ Previsualizar
+
+
+
+ ))}
+
+
+ {preview && (
+
+
+
Vista previa · {preview.fmt.title}
+
+
+ downloadString(
+ preview.content,
+ `${slug}.${preview.fmt.id}`,
+ preview.fmt.mime
+ )
+ }
+ >
+ Descargar
+
+ setPreview(null)}>
+ Cerrar
+
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/template/GenerateTab.jsx b/frontend/src/pages/template/GenerateTab.jsx
new file mode 100644
index 0000000..b1a5dd4
--- /dev/null
+++ b/frontend/src/pages/template/GenerateTab.jsx
@@ -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 (
+
+
+
+ {MODES.map((m) => (
+ setMode(m.id)}
+ >
+
+ {m.label}
+
+ ))}
+
+
+ {mode !== "parse" && (
+
+
+
0 ? "Escribe al menos 5 caracteres." : null}
+ >
+
+
+ {processedMaterials.length > 0 && (
+
+ setUseAllMaterials(e.target.checked)}
+ />
+ {!useAllMaterials && (
+
+ {processedMaterials.map((m) => (
+ toggleMaterial(m.id)}
+ />
+ ))}
+
+ )}
+
+ )}
+
+
+ {mode === "auto" ? (
+
+
+ Generar preguntas
+
+ ) : (
+
+
+ Construir prompt
+
+ )}
+
+
+
+ )}
+
+ {mode === "prompt" && prompt && (
+
+
+
Prompt generado
+
+
+ Copiar
+
+
+
+
{prompt}
+
+ Pega este prompt en tu LLM, copia su respuesta JSON y vuelve con
+ el modo “Pegar respuesta IA” para importar las
+ preguntas.
+
+
+
+ )}
+
+ {mode === "parse" && (
+
+
+
+ setInputFormat(e.target.value)}
+ >
+ JSON (recomendado)
+ Texto plano
+
+
+
+
+
+
+ Importar preguntas
+
+
+
+ )}
+
+ {generated.length > 0 && (
+
+
+
+ Resultado ({generated.length} preguntas)
+
+ goToTab("questions")}>
+ Ver todas las preguntas →
+
+
+ {generated.map((q, i) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
Resumen del objetivo
+
+
+
+ Preguntas objetivo
+ {expectedTotal}
+
+
+ Material procesado
+
+ {processedMaterials.length}
+
+
+
+
+
+
+
¿Cómo funciona?
+
+
+ Automática: el backend llama al LLM y guarda las
+ preguntas.
+
+
+ Solo prompt: obtienes el prompt para usarlo en
+ otro LLM.
+
+
+ Pegar respuesta: importas la salida JSON/TXT de
+ la IA.
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/template/ImagesTab.jsx b/frontend/src/pages/template/ImagesTab.jsx
new file mode 100644
index 0000000..368b912
--- /dev/null
+++ b/frontend/src/pages/template/ImagesTab.jsx
@@ -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 (
+
+
+
+
+ {images.length === 0 ? (
+
+
+
+ ) : (
+
+ {images.map((img) => (
+
+
+
+
{img.original_filename}
+
+ {formatBytes(img.size_bytes)}
+ {img.caption ? ` · ${img.caption}` : ""}
+
+
+
setToDelete(img)}
+ >
+ Eliminar
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
Consejo
+
+ Añade una descripción a cada imagen. La IA la usa para decidir a
+ qué pregunta corresponde cada imagen al generar el examen.
+
+
+
+
+
+ {/* Modal de caption antes de subir */}
+
!uploading && setPendingFile(null)}
+ title="Subir imagen"
+ footer={
+ <>
+ setPendingFile(null)}
+ disabled={uploading}
+ >
+ Cancelar
+
+
+ Subir imagen
+
+ >
+ }
+ >
+
+ Archivo: {pendingFile?.name} (
+ {formatBytes(pendingFile?.size)})
+
+
+ setCaption(e.target.value)}
+ placeholder="Ej. Diagrama del ciclo del agua"
+ maxLength={500}
+ />
+
+ {uploading && (
+
+ )}
+
+
+
setToDelete(null)}
+ />
+
+ );
+}
diff --git a/frontend/src/pages/template/MaterialsTab.jsx b/frontend/src/pages/template/MaterialsTab.jsx
new file mode 100644
index 0000000..8aa784d
--- /dev/null
+++ b/frontend/src/pages/template/MaterialsTab.jsx
@@ -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 (
+
+
+
+
+ {uploading ? (
+
+
+
Subiendo y procesando… {progress}%
+
+
+ ) : (
+
+ )}
+
+
+
+ {materials.length === 0 ? (
+
+
+
+ ) : (
+ materials.map((m) => {
+ const badge = STATUS_BADGE[m.status] || STATUS_BADGE.pending;
+ return (
+
+
+
+
+
+
{m.original_filename}
+
+ {formatBytes(m.size_bytes)} · {formatDate(m.created_at)}
+
+ {m.status === "failed" && m.error_message && (
+
{m.error_message}
+ )}
+ {m.text_preview && (
+
+ “{m.text_preview}”
+
+ )}
+
+
+ {badge.label}
+ setToDelete(m)}
+ >
+ Eliminar
+
+
+
+ );
+ })
+ )}
+
+
+
+
+
+
+
¿Para qué sirve?
+
+ 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 Imágenes .
+
+
+
+
+
+
setToDelete(null)}
+ />
+
+ );
+}
diff --git a/frontend/src/pages/template/OverviewTab.jsx b/frontend/src/pages/template/OverviewTab.jsx
new file mode 100644
index 0000000..060f5fd
--- /dev/null
+++ b/frontend/src/pages/template/OverviewTab.jsx
@@ -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 (
+
+
+
+
+
Estructura del examen
+
+
+
Tipos de pregunta
+ {qTypes.map((qt, i) => (
+
+
+ {QUESTION_TYPE_LABEL[qt.type] || qt.type}
+ {qt.type === "multichoice" && qt.options_count && (
+
+ {" "}· {qt.options_count} opciones
+ {qt.multiple_correct ? " · multi-respuesta" : ""}
+
+ )}
+
+
+ {qt.count} preg.
+
+ {qt.score} pt{qt.penalty ? ` · -${qt.penalty}` : ""}
+
+
+
+ ))}
+
+
+
Reparto por dificultad
+
+ {Object.entries(profile).map(([key, val]) =>
+ val > 0 ? (
+
+ {DIFFICULTY_LABEL[key]?.label || key}: {val}
+
+ ) : null
+ )}
+
+
+
+
+
+
+ Barajar preguntas
+
+
+
+ Barajar respuestas
+
+
+
+ Feedback
+
+
+
+
+
+
+
+
+
+
Almacenamiento
+
+
+
+
+
+
+
+
+
Siguiente paso
+
+
+ goToTab("materials")}>
+
+ Subir material para la IA
+
+ goToTab("images")}>
+
+ Añadir imágenes
+
+ goToTab("generate")}>
+
+ Generar preguntas
+
+
+
+
+
+
+ Creado el {formatDate(template.created_at)}
+
+ Actualizado el {formatDate(template.updated_at)}
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/template/QuestionsTab.jsx b/frontend/src/pages/template/QuestionsTab.jsx
new file mode 100644
index 0000000..50c3feb
--- /dev/null
+++ b/frontend/src/pages/template/QuestionsTab.jsx
@@ -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 (
+
+ goToTab("generate")}>
+
+ Generar preguntas
+
+ }
+ />
+
+ );
+ }
+
+ return (
+
+
+
+ {questions.length} preguntas guardadas. Vincula imágenes a las
+ preguntas que las necesiten.
+
+
goToTab("export")}>
+ Exportar examen →
+
+
+
+ {questions.map((q, i) => (
+
setEditing(q)}
+ disabled={images.length === 0}
+ >
+ {q.image_id ? "Cambiar imagen" : "Vincular imagen"}
+
+ }
+ />
+ ))}
+
+ {images.length === 0 && (
+
+ Sube imágenes en la pestaña Imágenes para poder
+ vincularlas a las preguntas.
+
+ )}
+
+ !saving && setEditing(null)}
+ title="Vincular imagen a la pregunta"
+ large
+ >
+ {editing?.statement}
+
+
setImage(editing.id, null)}
+ >
+
+
+
+ Sin imagen
+
+ {images.map((img) => {
+ const selected = editing?.image_id === img.id;
+ return (
+
setImage(editing.id, img.id)}
+ >
+
+
+ {img.caption || img.original_filename}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js
new file mode 100644
index 0000000..5a370c6
--- /dev/null
+++ b/frontend/src/utils/constants.js
@@ -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";
diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js
new file mode 100644
index 0000000..ddbc379
--- /dev/null
+++ b/frontend/src/utils/format.js
@@ -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)
+ );
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..77a11e3
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ host: true,
+ port: 5173,
+ },
+});