From 946f16a633839d9666a1e0f6c05020b716e88340 Mon Sep 17 00:00:00 2001 From: Mireya Cueto Garrido Date: Mon, 1 Jun 2026 13:27:41 +0200 Subject: [PATCH] Add React frontend and Sinbad2IA LLM integration. Introduce a full Vite/React UI for exams, auth, materials, images, generation, and export. Adapt backend for Sinbad2IA chat API, bcrypt passwords, CORS on port 5173, and schema migrations. --- README.md | 9 +- backend/.env.example | 14 +- backend/app/api/routes/templates.py | 11 +- backend/app/core/config.py | 8 +- backend/app/core/middleware.py | 3 + backend/app/db/init_db.py | 2 + backend/app/db/migrations.py | 104 + backend/app/main.py | 9 +- backend/app/services/auth_service.py | 16 +- backend/app/services/exam_service.py | 5 + backend/app/services/llm.py | 67 +- backend/requirements.txt | 2 +- docker-compose.yml | 17 +- frontend/.dockerignore | 6 + frontend/.env.example | 6 + frontend/.gitignore | 6 + frontend/Dockerfile | 20 + frontend/README.md | 71 + frontend/index.html | 14 + frontend/nginx.conf | 21 + frontend/package-lock.json | 2029 +++++++++++++++++ frontend/package.json | 21 + frontend/public/favicon.svg | 6 + frontend/src/App.jsx | 33 + frontend/src/api/auth.js | 25 + frontend/src/api/client.js | 100 + frontend/src/api/exports.js | 30 + frontend/src/api/generation.js | 27 + frontend/src/api/images.js | 41 + frontend/src/api/materials.js | 27 + frontend/src/api/questions.js | 8 + frontend/src/api/templates.js | 26 + frontend/src/components/AuthImage.jsx | 46 + frontend/src/components/AuthLayout.jsx | 54 + frontend/src/components/FileDropzone.jsx | 55 + frontend/src/components/QuestionCard.jsx | 68 + frontend/src/components/StorageBar.jsx | 36 + frontend/src/components/layout/Layout.jsx | 11 + frontend/src/components/layout/Navbar.jsx | 68 + .../src/components/layout/ProtectedRoute.jsx | 16 + frontend/src/components/ui/Button.jsx | 30 + frontend/src/components/ui/Field.jsx | 54 + frontend/src/components/ui/Icon.jsx | 178 ++ frontend/src/components/ui/Misc.jsx | 26 + frontend/src/components/ui/Modal.jsx | 79 + frontend/src/components/ui/Spinner.jsx | 21 + frontend/src/context/AuthContext.jsx | 106 + frontend/src/context/ToastContext.jsx | 68 + frontend/src/hooks/useGoogleSignIn.js | 55 + frontend/src/index.css | 935 ++++++++ frontend/src/main.jsx | 19 + frontend/src/pages/CreateTemplatePage.jsx | 357 +++ frontend/src/pages/DashboardPage.jsx | 96 + frontend/src/pages/LoginPage.jsx | 113 + frontend/src/pages/NotFoundPage.jsx | 19 + frontend/src/pages/RegisterPage.jsx | 146 ++ frontend/src/pages/TemplateDetailPage.jsx | 187 ++ frontend/src/pages/template/ExportTab.jsx | 138 ++ frontend/src/pages/template/GenerateTab.jsx | 302 +++ frontend/src/pages/template/ImagesTab.jsx | 189 ++ frontend/src/pages/template/MaterialsTab.jsx | 179 ++ frontend/src/pages/template/OverviewTab.jsx | 135 ++ frontend/src/pages/template/QuestionsTab.jsx | 143 ++ frontend/src/utils/constants.js | 26 + frontend/src/utils/format.js | 68 + frontend/vite.config.js | 10 + 66 files changed, 6769 insertions(+), 48 deletions(-) create mode 100644 backend/app/db/migrations.py create mode 100644 frontend/.dockerignore create mode 100644 frontend/.env.example create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api/auth.js create mode 100644 frontend/src/api/client.js create mode 100644 frontend/src/api/exports.js create mode 100644 frontend/src/api/generation.js create mode 100644 frontend/src/api/images.js create mode 100644 frontend/src/api/materials.js create mode 100644 frontend/src/api/questions.js create mode 100644 frontend/src/api/templates.js create mode 100644 frontend/src/components/AuthImage.jsx create mode 100644 frontend/src/components/AuthLayout.jsx create mode 100644 frontend/src/components/FileDropzone.jsx create mode 100644 frontend/src/components/QuestionCard.jsx create mode 100644 frontend/src/components/StorageBar.jsx create mode 100644 frontend/src/components/layout/Layout.jsx create mode 100644 frontend/src/components/layout/Navbar.jsx create mode 100644 frontend/src/components/layout/ProtectedRoute.jsx create mode 100644 frontend/src/components/ui/Button.jsx create mode 100644 frontend/src/components/ui/Field.jsx create mode 100644 frontend/src/components/ui/Icon.jsx create mode 100644 frontend/src/components/ui/Misc.jsx create mode 100644 frontend/src/components/ui/Modal.jsx create mode 100644 frontend/src/components/ui/Spinner.jsx create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/context/ToastContext.jsx create mode 100644 frontend/src/hooks/useGoogleSignIn.js create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/CreateTemplatePage.jsx create mode 100644 frontend/src/pages/DashboardPage.jsx create mode 100644 frontend/src/pages/LoginPage.jsx create mode 100644 frontend/src/pages/NotFoundPage.jsx create mode 100644 frontend/src/pages/RegisterPage.jsx create mode 100644 frontend/src/pages/TemplateDetailPage.jsx create mode 100644 frontend/src/pages/template/ExportTab.jsx create mode 100644 frontend/src/pages/template/GenerateTab.jsx create mode 100644 frontend/src/pages/template/ImagesTab.jsx create mode 100644 frontend/src/pages/template/MaterialsTab.jsx create mode 100644 frontend/src/pages/template/OverviewTab.jsx create mode 100644 frontend/src/pages/template/QuestionsTab.jsx create mode 100644 frontend/src/utils/constants.js create mode 100644 frontend/src/utils/format.js create mode 100644 frontend/vite.config.js 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 {alt; +} 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 ( +
+ +
+
{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 ( +
+
+ + + + + GenExámenes IA + + + +
+
+ {initials(user?.full_name || user?.email)} +
+ +
+
+ + setConfirmOut(false)} + title="Cerrar sesión" + footer={ + <> + + + + } + > +

+ ¿Seguro que quieres cerrar la sesión de {user?.email}? +

+
+
+ ); +} 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 ( + + ); +} 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 && ( + + )} + {children} + {error ? ( +
{error}
+ ) : hint ? ( +
{hint}
+ ) : null} +
+ ); +} + +export function Input({ error, className = "", ...props }) { + return ( + + ); +} + +export function Textarea({ error, mono, className = "", ...props }) { + return ( +