Merge pull request #5 from uja-dev-practices/feature/backend-v3
Add React frontend and Sinbad2IA LLM integration.
This commit is contained in:
@@ -11,7 +11,7 @@ El proyecto está centrado en backend. La carpeta `frontend` se mantiene vacía
|
|||||||
- FastAPI
|
- FastAPI
|
||||||
- PostgreSQL
|
- PostgreSQL
|
||||||
- SQLAlchemy
|
- SQLAlchemy
|
||||||
- Cliente LLM compatible con OpenAI Chat Completions
|
- Cliente LLM para Sinbad2IA UJA (`POST /api/chat`, modelo `qwen3.5:35b`)
|
||||||
- Docker Compose con servicios `backend`, `frontend` y `db`
|
- Docker Compose con servicios `backend`, `frontend` y `db`
|
||||||
|
|
||||||
## Puesta en Marcha
|
## Puesta en Marcha
|
||||||
@@ -44,9 +44,10 @@ Variables principales:
|
|||||||
- `JWT_EXPIRE_MINUTES`: duración del token de acceso.
|
- `JWT_EXPIRE_MINUTES`: duración del token de acceso.
|
||||||
- `GOOGLE_CLIENT_ID`: Client ID de OAuth 2.0 en Google Cloud Console (para `/auth/google`).
|
- `GOOGLE_CLIENT_ID`: Client ID de OAuth 2.0 en Google Cloud Console (para `/auth/google`).
|
||||||
- `DATABASE_URL`: conexión PostgreSQL usada por el backend.
|
- `DATABASE_URL`: conexión PostgreSQL usada por el backend.
|
||||||
- `LLM_API_KEY`: clave del proveedor LLM.
|
- `LLM_BASE_URL`: URL base del servidor (por defecto ``; el cliente usa `/api/chat`).
|
||||||
- `LLM_BASE_URL`: endpoint compatible con OpenAI.
|
- `LLM_MODEL`: modelo (por defecto `qwen3.5:35b`).
|
||||||
- `LLM_MODEL`: modelo usado para generar preguntas.
|
- `LLM_TIMEOUT_SECONDS`: tiempo máximo de espera (por defecto 180 s).
|
||||||
|
- `LLM_API_KEY`: opcional, solo si el servidor exige autenticación.
|
||||||
- `ALLOWED_ORIGINS`: orígenes permitidos por CORS.
|
- `ALLOWED_ORIGINS`: orígenes permitidos por CORS.
|
||||||
- `MAX_STORAGE_BYTES_PER_TEMPLATE`: cupo total de almacenamiento por examen (materiales + imágenes).
|
- `MAX_STORAGE_BYTES_PER_TEMPLATE`: cupo total de almacenamiento por examen (materiales + imágenes).
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ API_KEY=change-me-in-production-min-16-chars
|
|||||||
DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
|
DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
|
||||||
|
|
||||||
# --- CORS (orígenes del frontend, separados por coma) ---
|
# --- CORS (orígenes del frontend, separados por coma) ---
|
||||||
ALLOWED_ORIGINS=http://localhost:3000
|
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||||
|
|
||||||
# --- Rate limiting y tamaño de petición ---
|
# --- Rate limiting y tamaño de petición ---
|
||||||
RATE_LIMIT_REQUESTS=60
|
RATE_LIMIT_REQUESTS=60
|
||||||
@@ -39,8 +39,10 @@ JWT_EXPIRE_MINUTES=1440
|
|||||||
# El frontend obtiene un id_token con Google Identity Services y lo envía a POST /auth/google.
|
# El frontend obtiene un id_token con Google Identity Services y lo envía a POST /auth/google.
|
||||||
GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com
|
GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com
|
||||||
|
|
||||||
# --- LLM (OpenAI o compatible) ---
|
# --- LLM (Sinbad2IA UJA — sin clave) ---
|
||||||
LLM_API_KEY=sk-your-openai-api-key
|
# URL base del servidor; el cliente llama a {LLM_BASE_URL}/api/chat
|
||||||
LLM_BASE_URL=https://api.openai.com/v1
|
LLM_BASE_URL=
|
||||||
LLM_MODEL=gpt-4o-mini
|
LLM_MODEL=qwen3.5:35b
|
||||||
LLM_TIMEOUT_SECONDS=60
|
LLM_TIMEOUT_SECONDS=180
|
||||||
|
# Opcional, solo si el servidor exige autenticación:
|
||||||
|
# LLM_API_KEY=
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, status
|
|||||||
from app.api.dependencies import get_exam_service, get_storage_quota_service
|
from app.api.dependencies import get_exam_service, get_storage_quota_service
|
||||||
from app.core.auth import get_current_user
|
from app.core.auth import get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.exam import ExamTemplateCreate, ExamTemplateRead
|
from app.schemas.exam import ExamTemplateCreate, ExamTemplateRead, QuestionRead
|
||||||
from app.schemas.storage import TemplateStorageUsage
|
from app.schemas.storage import TemplateStorageUsage
|
||||||
from app.services.exam_service import ExamService
|
from app.services.exam_service import ExamService
|
||||||
from app.services.storage_quota import StorageQuotaService
|
from app.services.storage_quota import StorageQuotaService
|
||||||
@@ -40,6 +40,15 @@ def get_template(
|
|||||||
return service.get_template(current_user.id, template_id)
|
return service.get_template(current_user.id, template_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{template_id}/questions", response_model=list[QuestionRead])
|
||||||
|
def list_template_questions(
|
||||||
|
template_id: uuid.UUID,
|
||||||
|
current_user: Annotated[User, Depends(get_current_user)],
|
||||||
|
service: Annotated[ExamService, Depends(get_exam_service)],
|
||||||
|
) -> list[QuestionRead]:
|
||||||
|
return service.list_questions(current_user.id, template_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{template_id}/storage", response_model=TemplateStorageUsage)
|
@router.get("/{template_id}/storage", response_model=TemplateStorageUsage)
|
||||||
def get_template_storage_usage(
|
def get_template_storage_usage(
|
||||||
template_id: uuid.UUID,
|
template_id: uuid.UUID,
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ class Settings(BaseSettings):
|
|||||||
api_prefix: str = ""
|
api_prefix: str = ""
|
||||||
api_key: str = Field(min_length=16)
|
api_key: str = Field(min_length=16)
|
||||||
database_url: str = "postgresql+psycopg://genexamenes:genexamenes@localhost:5432/genexamenes"
|
database_url: str = "postgresql+psycopg://genexamenes:genexamenes@localhost:5432/genexamenes"
|
||||||
allowed_origins: str = "http://localhost:3000"
|
allowed_origins: str = "http://localhost:5173,http://localhost:3000"
|
||||||
rate_limit_requests: int = Field(default=60, ge=1)
|
rate_limit_requests: int = Field(default=60, ge=1)
|
||||||
rate_limit_window_seconds: int = Field(default=60, ge=1)
|
rate_limit_window_seconds: int = Field(default=60, ge=1)
|
||||||
max_request_bytes: int = Field(default=1_048_576, ge=1_024)
|
max_request_bytes: int = Field(default=1_048_576, ge=1_024)
|
||||||
llm_api_key: str | None = None
|
llm_api_key: str | None = None
|
||||||
llm_base_url: str = "https://api.openai.com/v1"
|
llm_base_url: str = ""
|
||||||
llm_model: str = "gpt-4o-mini"
|
llm_model: str = "qwen3.5:35b"
|
||||||
llm_timeout_seconds: int = Field(default=60, ge=5)
|
llm_timeout_seconds: int = Field(default=180, ge=5)
|
||||||
jwt_secret_key: str = Field(min_length=32)
|
jwt_secret_key: str = Field(min_length=32)
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
jwt_expire_minutes: int = Field(default=60 * 24, ge=5)
|
jwt_expire_minutes: int = Field(default=60 * 24, ge=5)
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
|
|||||||
self.requests: defaultdict[str, deque[float]] = defaultdict(deque)
|
self.requests: defaultdict[str, deque[float]] = defaultdict(deque)
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||||
|
if request.method == "OPTIONS":
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
client = request.client.host if request.client else "unknown"
|
client = request.client.host if request.client else "unknown"
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
bucket = self.requests[client]
|
bucket = self.requests[client]
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
|
from app.db.migrations import run_migrations
|
||||||
from app.db.session import engine
|
from app.db.session import engine
|
||||||
from app.models import exam, user # noqa: F401
|
from app.models import exam, user # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
run_migrations(engine)
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
Migraciones ligeras e idempotentes para bases de datos creadas antes de nuevas columnas.
|
||||||
|
Se ejecutan en cada arranque; solo aplican cambios que falten.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations(engine: Engine) -> None:
|
||||||
|
inspector = inspect(engine)
|
||||||
|
table_names = set(inspector.get_table_names())
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
if "users" in table_names and "exam_templates" in table_names:
|
||||||
|
_ensure_exam_templates_user_id(conn, inspector)
|
||||||
|
|
||||||
|
if "questions" in table_names:
|
||||||
|
_ensure_questions_image_id(conn, inspector)
|
||||||
|
|
||||||
|
if "exam_materials" in table_names:
|
||||||
|
_ensure_material_status_enum(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _column_names(inspector, table: str) -> set[str]:
|
||||||
|
return {col["name"] for col in inspector.get_columns(table)}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_exam_templates_user_id(conn, inspector) -> None:
|
||||||
|
if "user_id" in _column_names(inspector, "exam_templates"):
|
||||||
|
return
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
text("ALTER TABLE exam_templates ADD COLUMN user_id UUID")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plantillas antiguas (sin usuario): asignar al primer usuario registrado.
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE exam_templates
|
||||||
|
SET user_id = (SELECT id FROM users ORDER BY created_at ASC LIMIT 1)
|
||||||
|
WHERE user_id IS NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM users)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Si no hay usuarios o filas huérfanas, eliminar plantillas sin dueño.
|
||||||
|
conn.execute(text("DELETE FROM exam_templates WHERE user_id IS NULL"))
|
||||||
|
|
||||||
|
conn.execute(text("ALTER TABLE exam_templates ALTER COLUMN user_id SET NOT NULL"))
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
ALTER TABLE exam_templates
|
||||||
|
ADD CONSTRAINT fk_exam_templates_user_id
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_exam_templates_user_id ON exam_templates (user_id)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_questions_image_id(conn, inspector) -> None:
|
||||||
|
if "image_id" in _column_names(inspector, "questions"):
|
||||||
|
return
|
||||||
|
|
||||||
|
if "exam_images" not in set(inspector.get_table_names()):
|
||||||
|
return
|
||||||
|
|
||||||
|
conn.execute(text("ALTER TABLE questions ADD COLUMN image_id UUID"))
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
ALTER TABLE questions
|
||||||
|
ADD CONSTRAINT fk_questions_image_id
|
||||||
|
FOREIGN KEY (image_id) REFERENCES exam_images(id) ON DELETE SET NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text("CREATE INDEX IF NOT EXISTS ix_questions_image_id ON questions (image_id)")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_material_status_enum(conn) -> None:
|
||||||
|
# Asegura el tipo enum de PostgreSQL usado por exam_materials.status.
|
||||||
|
conn.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'materialstatus') THEN
|
||||||
|
CREATE TYPE materialstatus AS ENUM ('processed', 'failed');
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
+6
-3
@@ -21,15 +21,18 @@ def create_app() -> FastAPI:
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||||
|
|
||||||
|
# CORS debe ser el middleware más externo (añadirlo al final) para que
|
||||||
|
# las peticiones OPTIONS (preflight) respondan antes que rate limit, etc.
|
||||||
|
app.add_middleware(RequestSizeLimitMiddleware, settings=settings)
|
||||||
|
app.add_middleware(RateLimitMiddleware, settings=settings)
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["Authorization", "Content-Type", "X-API-Key"],
|
allow_headers=["Authorization", "Content-Type", "X-API-Key", "Accept"],
|
||||||
|
expose_headers=["Content-Disposition"],
|
||||||
)
|
)
|
||||||
app.add_middleware(RequestSizeLimitMiddleware, settings=settings)
|
|
||||||
app.add_middleware(RateLimitMiddleware, settings=settings)
|
|
||||||
|
|
||||||
register_exception_handlers(app)
|
register_exception_handlers(app)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from typing import Annotated
|
|||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
import bcrypt
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -18,7 +18,15 @@ from app.db.session import get_db
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.user import UserLogin, UserRead, UserRegister
|
from app.schemas.user import UserLogin, UserRead, UserRegister
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
def _hash_password(password: str) -> str:
|
||||||
|
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_password(password: str, password_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
@@ -34,7 +42,7 @@ class AuthService:
|
|||||||
|
|
||||||
user = User(
|
user = User(
|
||||||
email=email,
|
email=email,
|
||||||
password_hash=pwd_context.hash(payload.password),
|
password_hash=_hash_password(payload.password),
|
||||||
full_name=clean_text(payload.full_name, max_length=200) if payload.full_name else None,
|
full_name=clean_text(payload.full_name, max_length=200) if payload.full_name else None,
|
||||||
)
|
)
|
||||||
self.db.add(user)
|
self.db.add(user)
|
||||||
@@ -47,7 +55,7 @@ class AuthService:
|
|||||||
user = self.db.scalar(select(User).where(User.email == email))
|
user = self.db.scalar(select(User).where(User.email == email))
|
||||||
if user is None or user.password_hash is None:
|
if user is None or user.password_hash is None:
|
||||||
raise UnauthorizedError("Invalid email or password")
|
raise UnauthorizedError("Invalid email or password")
|
||||||
if not pwd_context.verify(payload.password, user.password_hash):
|
if not _verify_password(payload.password, user.password_hash):
|
||||||
raise UnauthorizedError("Invalid email or password")
|
raise UnauthorizedError("Invalid email or password")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ class ExamService:
|
|||||||
def get_template(self, user_id: uuid.UUID, template_id: uuid.UUID) -> ExamTemplateRead:
|
def get_template(self, user_id: uuid.UUID, template_id: uuid.UUID) -> ExamTemplateRead:
|
||||||
return self._template_read(self._get_user_template_or_404(user_id, template_id))
|
return self._template_read(self._get_user_template_or_404(user_id, template_id))
|
||||||
|
|
||||||
|
def list_questions(self, user_id: uuid.UUID, template_id: uuid.UUID) -> list[QuestionRead]:
|
||||||
|
template = self._get_user_template_or_404(user_id, template_id)
|
||||||
|
questions = sorted(template.questions, key=lambda q: q.created_at)
|
||||||
|
return [self.to_question_read(question) for question in questions]
|
||||||
|
|
||||||
def get_owned_template(self, user_id: uuid.UUID, template_id: uuid.UUID) -> ExamTemplate:
|
def get_owned_template(self, user_id: uuid.UUID, template_id: uuid.UUID) -> ExamTemplate:
|
||||||
return self._get_user_template_or_404(user_id, template_id)
|
return self._get_user_template_or_404(user_id, template_id)
|
||||||
|
|
||||||
|
|||||||
+46
-21
@@ -5,44 +5,69 @@ from app.core.errors import LLMUnavailableError
|
|||||||
|
|
||||||
|
|
||||||
class LLMClient:
|
class LLMClient:
|
||||||
|
"""Cliente para el API de chat de Sinbad2IA (Ollama-compatible en UJA)."""
|
||||||
|
|
||||||
def __init__(self, settings: Settings) -> None:
|
def __init__(self, settings: Settings) -> None:
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
|
||||||
async def generate(self, prompt: str) -> str:
|
def _chat_url(self) -> str:
|
||||||
if not self.settings.llm_api_key:
|
base = self.settings.llm_base_url.rstrip("/")
|
||||||
raise LLMUnavailableError("LLM_API_KEY is not configured")
|
if base.endswith("/api/chat"):
|
||||||
|
return base
|
||||||
|
return f"{base}/api/chat"
|
||||||
|
|
||||||
url = f"{self.settings.llm_base_url.rstrip('/')}/chat/completions"
|
async def generate(self, prompt: str) -> str:
|
||||||
payload = {
|
payload = {
|
||||||
"model": self.settings.llm_model,
|
"model": self.settings.llm_model,
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "user",
|
||||||
"content": "You generate safe, valid JSON exam questions for Moodle imports.",
|
"content": (
|
||||||
|
"Genera preguntas de examen en formato JSON válido para importar en Moodle. "
|
||||||
|
"Responde únicamente con el JSON solicitado, sin texto adicional ni bloques markdown.\n\n"
|
||||||
|
f"{prompt}"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{"role": "user", "content": prompt},
|
|
||||||
],
|
],
|
||||||
"temperature": 0.2,
|
"stream": False,
|
||||||
"response_format": {"type": "json_object"},
|
|
||||||
}
|
|
||||||
headers = {
|
|
||||||
"Authorization": f"Bearer {self.settings.llm_api_key}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if self.settings.llm_api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.settings.llm_api_key}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_seconds) as client:
|
async with httpx.AsyncClient(timeout=self.settings.llm_timeout_seconds) as client:
|
||||||
response = await client.post(url, json=payload, headers=headers)
|
response = await client.post(self._chat_url(), json=payload, headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
raise LLMUnavailableError("LLM request failed") from exc
|
raise LLMUnavailableError("LLM request failed") from exc
|
||||||
|
|
||||||
data = response.json()
|
content = _extract_assistant_content(response.json())
|
||||||
try:
|
if not content.strip():
|
||||||
content = data["choices"][0]["message"]["content"]
|
|
||||||
except (KeyError, IndexError, TypeError) as exc:
|
|
||||||
raise LLMUnavailableError("LLM response did not include message content") from exc
|
|
||||||
|
|
||||||
if not isinstance(content, str) or not content.strip():
|
|
||||||
raise LLMUnavailableError("LLM returned empty content")
|
raise LLMUnavailableError("LLM returned empty content")
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_assistant_content(data: object) -> str:
|
||||||
|
"""Soporta respuesta Sinbad2IA/Ollama (`message.content`) y OpenAI (`choices`)."""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise LLMUnavailableError("LLM response is not a JSON object")
|
||||||
|
|
||||||
|
message = data.get("message")
|
||||||
|
if isinstance(message, dict):
|
||||||
|
content = message.get("content")
|
||||||
|
if isinstance(content, str) and content.strip():
|
||||||
|
return content
|
||||||
|
|
||||||
|
choices = data.get("choices")
|
||||||
|
if isinstance(choices, list) and choices:
|
||||||
|
first = choices[0]
|
||||||
|
if isinstance(first, dict):
|
||||||
|
msg = first.get("message")
|
||||||
|
if isinstance(msg, dict):
|
||||||
|
content = msg.get("content")
|
||||||
|
if isinstance(content, str) and content.strip():
|
||||||
|
return content
|
||||||
|
|
||||||
|
raise LLMUnavailableError("LLM response did not include message content")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pydantic[email]
|
|||||||
python-dotenv
|
python-dotenv
|
||||||
httpx
|
httpx
|
||||||
orjson
|
orjson
|
||||||
passlib[bcrypt]
|
bcrypt>=4.0.1,<5
|
||||||
python-jose[cryptography]
|
python-jose[cryptography]
|
||||||
google-auth
|
google-auth
|
||||||
requests
|
requests
|
||||||
|
|||||||
+13
-4
@@ -6,6 +6,11 @@ services:
|
|||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
|
DATABASE_URL: postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
|
||||||
|
# Sobrescribe backend/.env si aún tiene el puerto 3000 del frontend antiguo.
|
||||||
|
ALLOWED_ORIGINS: http://localhost:5173,http://localhost:3000
|
||||||
|
LLM_BASE_URL:
|
||||||
|
LLM_MODEL: qwen3.5:35b
|
||||||
|
LLM_TIMEOUT_SECONDS: "180"
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -16,11 +21,15 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: nginx:1.27-alpine
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
args:
|
||||||
|
VITE_API_URL: ${VITE_API_URL:-http://localhost:8000}
|
||||||
|
VITE_GOOGLE_CLIENT_ID: ${VITE_GOOGLE_CLIENT_ID:-}
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "5173:80"
|
||||||
volumes:
|
depends_on:
|
||||||
- ./frontend:/usr/share/nginx/html:ro
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# URL base del backend (accesible desde el navegador)
|
||||||
|
VITE_API_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# (Opcional) Client ID de Google para "Iniciar sesión con Google".
|
||||||
|
# Debe coincidir con GOOGLE_CLIENT_ID del backend.
|
||||||
|
VITE_GOOGLE_CLIENT_ID=
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# --- Build stage ---
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG VITE_API_URL=http://localhost:8000
|
||||||
|
ARG VITE_GOOGLE_CLIENT_ID=
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Serve stage ---
|
||||||
|
FROM nginx:1.27-alpine AS serve
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# GenExámenes IA — Frontend
|
||||||
|
|
||||||
|
Interfaz web en **React + Vite** para el generador de exámenes con IA. Consume
|
||||||
|
la API del backend (FastAPI) y cubre todo el flujo: autenticación, plantillas,
|
||||||
|
material de contexto, imágenes, generación con IA, gestión de preguntas y
|
||||||
|
exportación a Moodle.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **React 18** + **React Router 6**
|
||||||
|
- **Vite 5** (build y dev server)
|
||||||
|
- **Axios** con interceptores para el token JWT y el manejo unificado de errores HTTP
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
api/ Cliente axios y un módulo por recurso (auth, templates, materials, images, ...)
|
||||||
|
components/ Componentes reutilizables (UI, layout, AuthImage, QuestionCard, ...)
|
||||||
|
context/ AuthContext (sesión) y ToastContext (notificaciones)
|
||||||
|
hooks/ useGoogleSignIn (login con Google opcional)
|
||||||
|
pages/ Páginas y pestañas del detalle de plantilla
|
||||||
|
utils/ Constantes y formateadores
|
||||||
|
```
|
||||||
|
|
||||||
|
## Desarrollo local
|
||||||
|
|
||||||
|
Requisitos: Node 20+ y el backend corriendo en `http://localhost:8000`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
cp .env.example .env # ajusta VITE_API_URL si es necesario
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables de entorno
|
||||||
|
|
||||||
|
| Variable | Descripción |
|
||||||
|
| ----------------------- | -------------------------------------------------------- |
|
||||||
|
| `VITE_API_URL` | URL base del backend (por defecto `http://localhost:8000`). |
|
||||||
|
| `VITE_GOOGLE_CLIENT_ID` | (Opcional) Client ID de Google. Si está vacío, se oculta el botón de Google. |
|
||||||
|
|
||||||
|
> Las variables `VITE_*` se incrustan en el build, por lo que apuntan al backend
|
||||||
|
> tal y como lo verá el navegador del usuario.
|
||||||
|
|
||||||
|
## Build de producción
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # genera dist/
|
||||||
|
npm run preview # sirve el build localmente
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
El `docker-compose.yml` de la raíz construye el frontend con un build multi-stage
|
||||||
|
(Node → Nginx) y lo publica en `http://localhost:5173`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Las variables `VITE_API_URL` y `VITE_GOOGLE_CLIENT_ID` pueden pasarse como
|
||||||
|
variables de entorno al ejecutar `docker compose`.
|
||||||
|
|
||||||
|
## Manejo de errores
|
||||||
|
|
||||||
|
Todas las respuestas de error del backend siguen el formato
|
||||||
|
`{ "error": { "code", "message", "details" } }`. El interceptor de Axios las
|
||||||
|
normaliza a una `ApiError` y la UI las muestra mediante *toasts*. Un `401`
|
||||||
|
global cierra la sesión automáticamente.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="GenExámenes IA — Generador de exámenes con IA y exportación a Moodle." />
|
||||||
|
<title>GenExámenes IA</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA: cualquier ruta desconocida sirve index.html (React Router).
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache de assets con hash.
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
}
|
||||||
Generated
+2029
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "genexamenes-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#6366f1"/>
|
||||||
|
<path d="M9 8h11l3 3v13a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1z" fill="#fff"/>
|
||||||
|
<path d="M19 8v3a1 1 0 0 0 1 1h3" fill="#c7d2fe"/>
|
||||||
|
<path d="M11 16h10M11 19.5h10M11 23h6" stroke="#6366f1" stroke-width="1.6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 366 B |
@@ -0,0 +1,33 @@
|
|||||||
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import Layout from "./components/layout/Layout";
|
||||||
|
import ProtectedRoute from "./components/layout/ProtectedRoute";
|
||||||
|
import LoginPage from "./pages/LoginPage";
|
||||||
|
import RegisterPage from "./pages/RegisterPage";
|
||||||
|
import DashboardPage from "./pages/DashboardPage";
|
||||||
|
import CreateTemplatePage from "./pages/CreateTemplatePage";
|
||||||
|
import TemplateDetailPage from "./pages/TemplateDetailPage";
|
||||||
|
import NotFoundPage from "./pages/NotFoundPage";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/registro" element={<RegisterPage />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Layout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/plantillas/nueva" element={<CreateTemplatePage />} />
|
||||||
|
<Route path="/plantillas/:templateId" element={<TemplateDetailPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/404" element={<NotFoundPage />} />
|
||||||
|
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export async function register({ email, password, full_name }) {
|
||||||
|
const { data } = await api.post("/auth/register", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
full_name: full_name || null,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login({ email, password }) {
|
||||||
|
const { data } = await api.post("/auth/login", { email, password });
|
||||||
|
return data; // { access_token, token_type }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithGoogle(idToken) {
|
||||||
|
const { data } = await api.post("/auth/google", { id_token: idToken });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMe() {
|
||||||
|
const { data } = await api.get("/auth/me");
|
||||||
|
return data; // { id, email, full_name, created_at }
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const API_URL =
|
||||||
|
import.meta.env.VITE_API_URL?.replace(/\/$/, "") || "http://localhost:8000";
|
||||||
|
|
||||||
|
const TOKEN_KEY = "genex_token";
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
export function setToken(token) {
|
||||||
|
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
export function clearToken() {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = getToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normaliza cualquier error de axios al formato de la API:
|
||||||
|
* { code, message, status, details }.
|
||||||
|
*/
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor({ message, code, status, details }) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eventos para que la app reaccione a un 401 global (sesión caducada).
|
||||||
|
const listeners = new Set();
|
||||||
|
export function onUnauthorized(fn) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(error) => {
|
||||||
|
if (!error.response) {
|
||||||
|
return Promise.reject(
|
||||||
|
new ApiError({
|
||||||
|
message:
|
||||||
|
"No se pudo conectar con el servidor. Comprueba que el backend está en marcha.",
|
||||||
|
code: "network_error",
|
||||||
|
status: 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, data } = error.response;
|
||||||
|
const apiErr = data?.error || {};
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
listeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(
|
||||||
|
new ApiError({
|
||||||
|
message:
|
||||||
|
apiErr.message ||
|
||||||
|
friendlyStatus(status) ||
|
||||||
|
"Se ha producido un error inesperado.",
|
||||||
|
code: apiErr.code || `http_${status}`,
|
||||||
|
status,
|
||||||
|
details: apiErr.details || null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function friendlyStatus(status) {
|
||||||
|
const map = {
|
||||||
|
400: "Solicitud incorrecta.",
|
||||||
|
401: "Sesión no válida o caducada.",
|
||||||
|
403: "No tienes acceso a este recurso.",
|
||||||
|
404: "Recurso no encontrado.",
|
||||||
|
409: "Conflicto con el estado actual.",
|
||||||
|
413: "El archivo o la petición es demasiado grande.",
|
||||||
|
422: "Los datos enviados no son válidos.",
|
||||||
|
429: "Demasiadas peticiones, inténtalo más tarde.",
|
||||||
|
500: "Error interno del servidor.",
|
||||||
|
503: "Servicio no disponible.",
|
||||||
|
};
|
||||||
|
return map[status];
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
const FORMATS = {
|
||||||
|
xml: { path: "xml", ext: "xml", responseType: "text" },
|
||||||
|
txt: { path: "txt", ext: "txt", responseType: "text" },
|
||||||
|
json: { path: "json", ext: "json", responseType: "text" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchExport(templateId, format) {
|
||||||
|
const cfg = FORMATS[format];
|
||||||
|
const res = await api.get(`/exam/export/${cfg.path}/${templateId}`, {
|
||||||
|
responseType: "text",
|
||||||
|
transformResponse: (d) => d,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadString(content, filename, mime) {
|
||||||
|
const blob = new Blob([content], { type: mime || "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EXPORT_FORMATS = FORMATS;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export async function buildPrompt(templateId, { topic_prompt, material_ids }) {
|
||||||
|
const { data } = await api.post(`/exam/prompts/${templateId}`, {
|
||||||
|
topic_prompt,
|
||||||
|
material_ids: material_ids?.length ? material_ids : null,
|
||||||
|
});
|
||||||
|
return data; // { template_id, prompt, expected_format }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateExam({ template_id, topic_prompt, material_ids }) {
|
||||||
|
const { data } = await api.post("/exam/generate", {
|
||||||
|
template_id,
|
||||||
|
topic_prompt,
|
||||||
|
material_ids: material_ids?.length ? material_ids : null,
|
||||||
|
});
|
||||||
|
return data; // { questions: [...] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseOutput({ template_id, raw_output, input_format }) {
|
||||||
|
const { data } = await api.post("/exam/parse", {
|
||||||
|
template_id,
|
||||||
|
raw_output,
|
||||||
|
input_format,
|
||||||
|
});
|
||||||
|
return data; // { questions: [...] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { api, API_URL, getToken } from "./client";
|
||||||
|
|
||||||
|
export async function uploadImage(templateId, file, caption, onProgress) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
if (caption) form.append("caption", caption);
|
||||||
|
const { data } = await api.post(
|
||||||
|
`/exam/templates/${templateId}/images`,
|
||||||
|
form,
|
||||||
|
{
|
||||||
|
onUploadProgress: (e) => {
|
||||||
|
if (onProgress && e.total) {
|
||||||
|
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data; // { image, message }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listImages(templateId) {
|
||||||
|
const { data } = await api.get(`/exam/templates/${templateId}/images`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteImage(templateId, imageId) {
|
||||||
|
await api.delete(`/exam/templates/${templateId}/images/${imageId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El contenido de imagen requiere Authorization, así que lo descargamos
|
||||||
|
* como blob y devolvemos una object URL para usar en <img src>.
|
||||||
|
*/
|
||||||
|
export async function fetchImageBlobUrl(imageId) {
|
||||||
|
const res = await api.get(`/exam/images/${imageId}/content`, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
return URL.createObjectURL(res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { API_URL, getToken };
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export async function uploadMaterial(templateId, file, onProgress) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const { data } = await api.post(
|
||||||
|
`/exam/templates/${templateId}/materials`,
|
||||||
|
form,
|
||||||
|
{
|
||||||
|
onUploadProgress: (e) => {
|
||||||
|
if (onProgress && e.total) {
|
||||||
|
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return data; // { material, message }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMaterials(templateId) {
|
||||||
|
const { data } = await api.get(`/exam/templates/${templateId}/materials`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMaterial(templateId, materialId) {
|
||||||
|
await api.delete(`/exam/templates/${templateId}/materials/${materialId}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export async function attachImageToQuestion(questionId, imageId) {
|
||||||
|
const { data } = await api.patch(`/exam/questions/${questionId}/image`, {
|
||||||
|
image_id: imageId,
|
||||||
|
});
|
||||||
|
return data; // QuestionRead
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export async function createTemplate(payload) {
|
||||||
|
const { data } = await api.post("/exam/templates", payload);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTemplates() {
|
||||||
|
const { data } = await api.get("/exam/templates");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplate(templateId) {
|
||||||
|
const { data } = await api.get(`/exam/templates/${templateId}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listQuestions(templateId) {
|
||||||
|
const { data } = await api.get(`/exam/templates/${templateId}/questions`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplateStorage(templateId) {
|
||||||
|
const { data } = await api.get(`/exam/templates/${templateId}/storage`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { fetchImageBlobUrl } from "../api/images";
|
||||||
|
import Spinner from "./ui/Spinner";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* El endpoint de contenido de imagen requiere Authorization, por lo que
|
||||||
|
* no se puede usar directamente en src. Descargamos el blob con el token.
|
||||||
|
*/
|
||||||
|
export default function AuthImage({ imageId, alt, className = "thumb" }) {
|
||||||
|
const [url, setUrl] = useState(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
let objectUrl;
|
||||||
|
setUrl(null);
|
||||||
|
setError(false);
|
||||||
|
fetchImageBlobUrl(imageId)
|
||||||
|
.then((u) => {
|
||||||
|
objectUrl = u;
|
||||||
|
if (active) setUrl(u);
|
||||||
|
else URL.revokeObjectURL(u);
|
||||||
|
})
|
||||||
|
.catch(() => active && setError(true));
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
}, [imageId]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ display: "grid", placeItems: "center" }}>
|
||||||
|
<span className="text-faint text-sm">No disponible</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!url) {
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ display: "grid", placeItems: "center" }}>
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <img src={url} alt={alt || "Imagen"} className={className} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Icon from "./ui/Icon";
|
||||||
|
|
||||||
|
export default function AuthLayout({ children }) {
|
||||||
|
return (
|
||||||
|
<div className="auth-wrap">
|
||||||
|
<aside className="auth-aside">
|
||||||
|
<div className="brand" style={{ color: "#fff" }}>
|
||||||
|
<span className="brand-logo" style={{ background: "rgba(255,255,255,.18)" }}>
|
||||||
|
<Icon name="document" size={18} />
|
||||||
|
</span>
|
||||||
|
GenExámenes IA
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Crea exámenes con IA y expórtalos a Moodle en minutos.</h2>
|
||||||
|
<p style={{ marginBottom: 32 }}>
|
||||||
|
Define la plantilla, sube tu material de estudio y deja que la IA
|
||||||
|
redacte las preguntas por ti.
|
||||||
|
</p>
|
||||||
|
<Feature icon="cpu" title="Generación con IA">
|
||||||
|
Preguntas tipo test, V/F, respuesta corta y emparejamiento.
|
||||||
|
</Feature>
|
||||||
|
<Feature icon="book" title="Material de contexto">
|
||||||
|
Sube PDF, DOCX, TXT o imágenes y la IA usará su contenido.
|
||||||
|
</Feature>
|
||||||
|
<Feature icon="moodle" title="Exportación Moodle">
|
||||||
|
Descarga el examen en XML compatible con Moodle, TXT o JSON.
|
||||||
|
</Feature>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm" style={{ opacity: 0.7, margin: 0 }}>
|
||||||
|
© {new Date().getFullYear()} GenExámenes IA
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
<main className="auth-main">
|
||||||
|
<div className="auth-card">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Feature({ icon, title, children }) {
|
||||||
|
return (
|
||||||
|
<div className="auth-feature">
|
||||||
|
<div className="auth-feature-icon">
|
||||||
|
<Icon name={icon} size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
<div style={{ color: "rgba(255,255,255,.78)", fontSize: 13.5 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import Icon from "./ui/Icon";
|
||||||
|
|
||||||
|
export default function FileDropzone({ accept, onFile, hint, icon = "paperclip" }) {
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const [drag, setDrag] = useState(false);
|
||||||
|
|
||||||
|
const handleFiles = (files) => {
|
||||||
|
if (files && files.length) onFile(files[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
borderStyle: "dashed",
|
||||||
|
borderColor: drag ? "var(--c-primary)" : "var(--c-border-strong)",
|
||||||
|
background: drag ? "var(--c-primary-soft)" : "var(--c-surface)",
|
||||||
|
padding: "30px 22px",
|
||||||
|
textAlign: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.15s",
|
||||||
|
}}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDrag(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDrag(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDrag(false);
|
||||||
|
handleFiles(e.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="icon-wrap icon-box icon-box-lg" style={{ margin: "0 auto 12px" }}>
|
||||||
|
<Icon name={icon} size={26} className="icon-primary" />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>
|
||||||
|
Arrastra un archivo o haz clic para seleccionar
|
||||||
|
</div>
|
||||||
|
{hint && <div className="field-hint">{hint}</div>}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleFiles(e.target.files);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import AuthImage from "./AuthImage";
|
||||||
|
import { Badge } from "./ui/Misc";
|
||||||
|
import Icon from "./ui/Icon";
|
||||||
|
import {
|
||||||
|
QUESTION_TYPE_LABEL,
|
||||||
|
DIFFICULTY_LABEL,
|
||||||
|
} from "../utils/constants";
|
||||||
|
|
||||||
|
export default function QuestionCard({ question, index, footer }) {
|
||||||
|
const diff = DIFFICULTY_LABEL[question.difficulty];
|
||||||
|
return (
|
||||||
|
<div className="card card-pad mb">
|
||||||
|
<div className="flex justify-between items-center mb wrap gap-sm">
|
||||||
|
<div className="flex gap-sm items-center wrap">
|
||||||
|
<Badge variant="primary">#{index}</Badge>
|
||||||
|
<Badge>{QUESTION_TYPE_LABEL[question.question_type] || question.question_type}</Badge>
|
||||||
|
{diff && <Badge variant={diff.badge.replace("badge-", "")}>{diff.label}</Badge>}
|
||||||
|
<span className="text-sm text-faint">
|
||||||
|
{question.score} pt{question.penalty ? ` · -${question.penalty}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{question.image_id && (
|
||||||
|
<Badge variant="info">
|
||||||
|
<Icon name="image" size={12} className="icon-inline" />
|
||||||
|
Con imagen
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontWeight: 600, marginTop: 0 }}>{question.statement}</p>
|
||||||
|
|
||||||
|
{question.image_id && (
|
||||||
|
<div style={{ maxWidth: 320, margin: "10px 0" }}>
|
||||||
|
<AuthImage imageId={question.image_id} alt="Imagen de la pregunta" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{question.question_type === "matching" ? (
|
||||||
|
<div className="grid grid-2">
|
||||||
|
{(question.matching_pairs || []).map((p, i) => (
|
||||||
|
<div key={i} className="text-sm" style={{ display: "flex", gap: 8 }}>
|
||||||
|
<span>{p.prompt}</span>
|
||||||
|
<span className="text-faint">↔</span>
|
||||||
|
<strong>{p.answer}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex" style={{ flexDirection: "column", gap: 6 }}>
|
||||||
|
{(question.correct_answers || []).map((a, i) => (
|
||||||
|
<div key={`c${i}`} className="text-sm icon-wrap" style={{ color: "var(--c-success)" }}>
|
||||||
|
<Icon name="check" size={14} className="icon-inline icon-success" />
|
||||||
|
{a}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(question.wrong_answers || []).map((a, i) => (
|
||||||
|
<div key={`w${i}`} className="text-sm text-soft icon-wrap">
|
||||||
|
<Icon name="x" size={14} className="icon-inline icon-muted" />
|
||||||
|
{a}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{footer && <div className="mt">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { formatBytes } from "../utils/format";
|
||||||
|
import Icon from "./ui/Icon";
|
||||||
|
|
||||||
|
export default function StorageBar({ storage }) {
|
||||||
|
if (!storage) return null;
|
||||||
|
const pct = storage.limit_bytes
|
||||||
|
? Math.min(100, Math.round((storage.used_bytes / storage.limit_bytes) * 100))
|
||||||
|
: 0;
|
||||||
|
const level = pct >= 90 ? "danger" : pct >= 70 ? "warn" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-sm mb" style={{ marginBottom: 6 }}>
|
||||||
|
<span className="text-soft">
|
||||||
|
Almacenamiento del examen ({formatBytes(storage.used_bytes)} /{" "}
|
||||||
|
{formatBytes(storage.limit_bytes)})
|
||||||
|
</span>
|
||||||
|
<strong>{pct}%</strong>
|
||||||
|
</div>
|
||||||
|
<div className="progress">
|
||||||
|
<div className={`progress-bar ${level}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap text-sm text-faint" style={{ marginTop: 8 }}>
|
||||||
|
<span className="icon-wrap">
|
||||||
|
<Icon name="book" size={14} className="icon-inline" />
|
||||||
|
Materiales: {formatBytes(storage.materials_bytes)}
|
||||||
|
</span>
|
||||||
|
<span className="icon-wrap">
|
||||||
|
<Icon name="image" size={14} className="icon-inline" />
|
||||||
|
Imágenes: {formatBytes(storage.images_bytes)}
|
||||||
|
</span>
|
||||||
|
<span>Disponible: {formatBytes(storage.remaining_bytes)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import Navbar from "./Navbar";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<Navbar />
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, NavLink, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
import { initials } from "../../utils/format";
|
||||||
|
import Modal from "../ui/Modal";
|
||||||
|
import Button from "../ui/Button";
|
||||||
|
import Icon from "../ui/Icon";
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [confirmOut, setConfirmOut] = useState(false);
|
||||||
|
|
||||||
|
const doLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="navbar">
|
||||||
|
<div className="navbar-inner">
|
||||||
|
<Link to="/" className="brand">
|
||||||
|
<span className="brand-logo">
|
||||||
|
<Icon name="document" size={18} />
|
||||||
|
</span>
|
||||||
|
GenExámenes IA
|
||||||
|
</Link>
|
||||||
|
<nav className="nav-links">
|
||||||
|
<NavLink to="/" end className="nav-link">
|
||||||
|
Mis exámenes
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/plantillas/nueva" className="nav-link">
|
||||||
|
Crear examen
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
<span className="nav-spacer" />
|
||||||
|
<div className="nav-user">
|
||||||
|
<div className="avatar" title={user?.email}>
|
||||||
|
{initials(user?.full_name || user?.email)}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setConfirmOut(true)}>
|
||||||
|
Salir
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={confirmOut}
|
||||||
|
onClose={() => setConfirmOut(false)}
|
||||||
|
title="Cerrar sesión"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setConfirmOut(false)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={doLogout}>
|
||||||
|
Cerrar sesión
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-soft" style={{ margin: 0 }}>
|
||||||
|
¿Seguro que quieres cerrar la sesión de <strong>{user?.email}</strong>?
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "../../context/AuthContext";
|
||||||
|
import { SpinnerCenter } from "../ui/Spinner";
|
||||||
|
|
||||||
|
export default function ProtectedRoute({ children }) {
|
||||||
|
const { isAuthenticated, loading } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (loading) return <SpinnerCenter label="Cargando tu sesión…" />;
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Spinner from "./Spinner";
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
variant = "primary",
|
||||||
|
size,
|
||||||
|
block,
|
||||||
|
loading,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}) {
|
||||||
|
const classes = [
|
||||||
|
"btn",
|
||||||
|
`btn-${variant}`,
|
||||||
|
size === "sm" ? "btn-sm" : "",
|
||||||
|
size === "lg" ? "btn-lg" : "",
|
||||||
|
block ? "btn-block" : "",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={classes} disabled={disabled || loading} {...props}>
|
||||||
|
{loading && <Spinner light={variant === "primary" || variant === "danger"} />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export function Field({ label, hint, error, children, htmlFor }) {
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
{label && (
|
||||||
|
<label className="field-label" htmlFor={htmlFor}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{error ? (
|
||||||
|
<div className="field-error">{error}</div>
|
||||||
|
) : hint ? (
|
||||||
|
<div className="field-hint">{hint}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ error, className = "", ...props }) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={`input ${error ? "has-error" : ""} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Textarea({ error, mono, className = "", ...props }) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={`textarea ${mono ? "textarea-mono" : ""} ${
|
||||||
|
error ? "has-error" : ""
|
||||||
|
} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ className = "", children, ...props }) {
|
||||||
|
return (
|
||||||
|
<select className={`select ${className}`} {...props}>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Checkbox({ label, checked, onChange, ...props }) {
|
||||||
|
return (
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="checkbox" checked={checked} onChange={onChange} {...props} />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
const PATHS = {
|
||||||
|
document: (
|
||||||
|
<>
|
||||||
|
<path d="M7 3h7l3 3v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" />
|
||||||
|
<path d="M14 3v3a1 1 0 0 0 1 1h3" />
|
||||||
|
<path d="M9 12h6M9 15h6M9 18h4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
book: (
|
||||||
|
<>
|
||||||
|
<path d="M5 4h9a2 2 0 0 1 2 2v14H7a2 2 0 0 1-2-2V4z" />
|
||||||
|
<path d="M5 18h9a2 2 0 0 0 2-2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
image: (
|
||||||
|
<>
|
||||||
|
<rect x="4" y="5" width="16" height="14" rx="2" />
|
||||||
|
<circle cx="9" cy="10" r="1.5" />
|
||||||
|
<path d="M6 17l4-4 3 3 2-2 3 3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
sparkles: (
|
||||||
|
<>
|
||||||
|
<path d="M12 3l1.2 4.2L17 8l-3.8 1.2L12 14l-1.2-4.8L7 8l3.8-0.8L12 3z" />
|
||||||
|
<path d="M18 14l0.6 2.1L21 17l-2.1 0.6L18 20l-0.6-2.4L15 17l2.4-0.9L18 14z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
help: <><circle cx="12" cy="12" r="9" /><path d="M9.5 9.5a2.5 2.5 0 1 1 4.2 1.8c-.8.7-1.2 1.4-1.2 2.7M12 17h.01" /></>,
|
||||||
|
upload: (
|
||||||
|
<>
|
||||||
|
<path d="M12 16V6M8 10l4-4 4 4" />
|
||||||
|
<path d="M5 20h14" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
clipboard: (
|
||||||
|
<>
|
||||||
|
<rect x="7" y="4" width="10" height="4" rx="1" />
|
||||||
|
<path d="M6 8h12v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V8z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
lock: (
|
||||||
|
<>
|
||||||
|
<rect x="6" y="11" width="12" height="9" rx="2" />
|
||||||
|
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
folder: (
|
||||||
|
<>
|
||||||
|
<path d="M4 7h6l2 2h8v10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
clock: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 7v5l3 2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
inbox: (
|
||||||
|
<>
|
||||||
|
<path d="M4 6h16v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6z" />
|
||||||
|
<path d="M4 10h5l2 3h2l2-3h5" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
compass: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M14.5 9.5L10 14l-2.5-2.5L12 10l2.5-0.5z" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
file: (
|
||||||
|
<>
|
||||||
|
<path d="M8 4h8l2 2v14H8a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1z" />
|
||||||
|
<path d="M14 4v3h3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
graduation: (
|
||||||
|
<>
|
||||||
|
<path d="M3 10l9-5 9 5-9 5-9-5z" />
|
||||||
|
<path d="M6 12v4c0 1.5 2.7 3 6 3s6-1.5 6-3v-4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
globe: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
cpu: (
|
||||||
|
<>
|
||||||
|
<rect x="7" y="7" width="10" height="10" rx="1" />
|
||||||
|
<path d="M9 3v2M15 3v2M9 19v2M15 19v2M3 9h2M3 15h2M19 9h2M19 15h2" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
paperclip: (
|
||||||
|
<path d="M8 12.5V7a4 4 0 0 1 8 0v8a3 3 0 0 1-6 0V8" />
|
||||||
|
),
|
||||||
|
close: (
|
||||||
|
<>
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
check: <path d="M5 12l5 5L19 7" />,
|
||||||
|
x: (
|
||||||
|
<>
|
||||||
|
<path d="M8 8l8 8M16 8l-8 8" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 11v6M12 8h.01" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
ban: (
|
||||||
|
<>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M7 7l10 10" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
listChecks: (
|
||||||
|
<>
|
||||||
|
<path d="M9 6h11M9 12h11M9 18h11" />
|
||||||
|
<path d="M5 6h.01M5 12h.01M5 18h.01" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
toggle: <circle cx="12" cy="12" r="4" />,
|
||||||
|
pencil: (
|
||||||
|
<>
|
||||||
|
<path d="M4 20h4l10-10-4-4L4 16v4z" />
|
||||||
|
<path d="M14 6l4 4" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
link: (
|
||||||
|
<>
|
||||||
|
<path d="M10 14a4 4 0 0 1 0-6l1-1a4 4 0 0 1 6 6l-1 1" />
|
||||||
|
<path d="M14 10a4 4 0 0 1 0 6l-1 1a4 4 0 0 1-6-6l1-1" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
download: (
|
||||||
|
<>
|
||||||
|
<path d="M12 5v10M8 13l4 4 4-4" />
|
||||||
|
<path d="M5 19h14" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
moodle: (
|
||||||
|
<>
|
||||||
|
<path d="M4 18V8l8-4 8 4v10" />
|
||||||
|
<path d="M8 14l4 3 4-3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
plus: (
|
||||||
|
<>
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Icon({ name, size = 18, className = "", strokeWidth = 1.75 }) {
|
||||||
|
const content = PATHS[name];
|
||||||
|
if (!content) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={`icon ${className}`}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
export function Badge({ variant, children }) {
|
||||||
|
return <span className={`badge ${variant ? `badge-${variant}` : ""}`}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ icon = "inbox", title, message, action }) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon icon-wrap icon-box icon-box-lg">
|
||||||
|
<Icon name={icon} size={28} className="icon-muted" />
|
||||||
|
</div>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{message && <p>{message}</p>}
|
||||||
|
{action && <div className="mt">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className = "", ...props }) {
|
||||||
|
return (
|
||||||
|
<div className={`card ${className}`} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import Button from "./Button";
|
||||||
|
import Icon from "./Icon";
|
||||||
|
|
||||||
|
export default function Modal({ open, onClose, title, children, footer, large }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e) => e.key === "Escape" && onClose?.();
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
const prevOverflow = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
document.body.style.overflow = prevOverflow;
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="modal-overlay" onMouseDown={onClose}>
|
||||||
|
<div
|
||||||
|
className={`modal ${large ? "modal-lg" : ""}`}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div className="modal-head">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button className="toast-close" type="button" aria-label="Cerrar" onClick={onClose}>
|
||||||
|
<Icon name="close" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="modal-body">{children}</div>
|
||||||
|
{footer && <div className="modal-foot">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title = "¿Confirmar?",
|
||||||
|
message,
|
||||||
|
confirmLabel = "Confirmar",
|
||||||
|
danger,
|
||||||
|
loading,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={title}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={loading}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={danger ? "danger" : "primary"}
|
||||||
|
onClick={onConfirm}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-soft" style={{ margin: 0 }}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export default function Spinner({ large, light }) {
|
||||||
|
const classes = [
|
||||||
|
"spinner",
|
||||||
|
large ? "spinner-lg" : "",
|
||||||
|
light ? "spinner-light" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return <span className={classes} aria-label="Cargando" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SpinnerCenter({ label }) {
|
||||||
|
return (
|
||||||
|
<div className="spinner-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Spinner large />
|
||||||
|
{label && <p className="text-soft mt">{label}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
clearToken,
|
||||||
|
getToken,
|
||||||
|
onUnauthorized,
|
||||||
|
setToken,
|
||||||
|
} from "../api/client";
|
||||||
|
import * as authApi from "../api/auth";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
clearToken();
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Carga inicial: si hay token, intenta recuperar el usuario.
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
async function bootstrap() {
|
||||||
|
if (!getToken()) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const me = await authApi.getMe();
|
||||||
|
if (active) setUser(me);
|
||||||
|
} catch {
|
||||||
|
clearToken();
|
||||||
|
} finally {
|
||||||
|
if (active) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reacciona a 401 global (token caducado).
|
||||||
|
useEffect(() => onUnauthorized(() => logout()), [logout]);
|
||||||
|
|
||||||
|
const finishLogin = useCallback(async (tokenResponse) => {
|
||||||
|
setToken(tokenResponse.access_token);
|
||||||
|
const me = await authApi.getMe();
|
||||||
|
setUser(me);
|
||||||
|
return me;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = useCallback(
|
||||||
|
async (credentials) => {
|
||||||
|
const res = await authApi.login(credentials);
|
||||||
|
return finishLogin(res);
|
||||||
|
},
|
||||||
|
[finishLogin]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loginWithGoogle = useCallback(
|
||||||
|
async (idToken) => {
|
||||||
|
const res = await authApi.loginWithGoogle(idToken);
|
||||||
|
return finishLogin(res);
|
||||||
|
},
|
||||||
|
[finishLogin]
|
||||||
|
);
|
||||||
|
|
||||||
|
const register = useCallback(async (payload) => {
|
||||||
|
await authApi.register(payload);
|
||||||
|
// Tras registrar, iniciamos sesión automáticamente.
|
||||||
|
const res = await authApi.login({
|
||||||
|
email: payload.email,
|
||||||
|
password: payload.password,
|
||||||
|
});
|
||||||
|
setToken(res.access_token);
|
||||||
|
const me = await authApi.getMe();
|
||||||
|
setUser(me);
|
||||||
|
return me;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
login,
|
||||||
|
loginWithGoogle,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth debe usarse dentro de AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { createContext, useCallback, useContext, useState } from "react";
|
||||||
|
import Icon from "../components/ui/Icon";
|
||||||
|
|
||||||
|
const ToastContext = createContext(null);
|
||||||
|
|
||||||
|
let idSeq = 0;
|
||||||
|
|
||||||
|
const TOAST_ICONS = { success: "check", error: "x", info: "info" };
|
||||||
|
|
||||||
|
export function ToastProvider({ children }) {
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
|
||||||
|
const remove = useCallback((id) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const push = useCallback(
|
||||||
|
(toast) => {
|
||||||
|
const id = ++idSeq;
|
||||||
|
const item = { id, duration: 4500, ...toast };
|
||||||
|
setToasts((prev) => [...prev, item]);
|
||||||
|
if (item.duration > 0) {
|
||||||
|
setTimeout(() => remove(id), item.duration);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
[remove]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toast = {
|
||||||
|
success: (msg, title = "Hecho") => push({ type: "success", title, msg }),
|
||||||
|
error: (msg, title = "Error") => push({ type: "error", title, msg }),
|
||||||
|
info: (msg, title = "Información") => push({ type: "info", title, msg }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={toast}>
|
||||||
|
{children}
|
||||||
|
<div className="toast-stack">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div key={t.id} className={`toast toast-${t.type}`} role="alert">
|
||||||
|
<span className={`toast-icon icon-${t.type}`}>
|
||||||
|
<Icon name={TOAST_ICONS[t.type]} size={16} />
|
||||||
|
</span>
|
||||||
|
<div className="toast-content">
|
||||||
|
<div className="toast-title">{t.title}</div>
|
||||||
|
{t.msg && <div className="toast-msg">{t.msg}</div>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="toast-close"
|
||||||
|
type="button"
|
||||||
|
aria-label="Cerrar"
|
||||||
|
onClick={() => remove(t.id)}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) throw new Error("useToast debe usarse dentro de ToastProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || "";
|
||||||
|
const SCRIPT_SRC = "https://accounts.google.com/gsi/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carga Google Identity Services y renderiza el botón oficial.
|
||||||
|
* Solo se activa si VITE_GOOGLE_CLIENT_ID está configurado.
|
||||||
|
* onCredential recibe el id_token que enviaremos a /auth/google.
|
||||||
|
*/
|
||||||
|
export function useGoogleSignIn(onCredential) {
|
||||||
|
const buttonRef = useRef(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const enabled = Boolean(GOOGLE_CLIENT_ID);
|
||||||
|
const callbackRef = useRef(onCredential);
|
||||||
|
callbackRef.current = onCredential;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (!window.google?.accounts?.id) return;
|
||||||
|
window.google.accounts.id.initialize({
|
||||||
|
client_id: GOOGLE_CLIENT_ID,
|
||||||
|
callback: (response) => callbackRef.current?.(response.credential),
|
||||||
|
});
|
||||||
|
if (buttonRef.current) {
|
||||||
|
window.google.accounts.id.renderButton(buttonRef.current, {
|
||||||
|
theme: "outline",
|
||||||
|
size: "large",
|
||||||
|
width: 340,
|
||||||
|
text: "continue_with",
|
||||||
|
shape: "rectangular",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = document.querySelector(`script[src="${SCRIPT_SRC}"]`);
|
||||||
|
if (existing) {
|
||||||
|
if (window.google?.accounts?.id) init();
|
||||||
|
else existing.addEventListener("load", init);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = SCRIPT_SRC;
|
||||||
|
script.async = true;
|
||||||
|
script.defer = true;
|
||||||
|
script.onload = init;
|
||||||
|
document.body.appendChild(script);
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return { buttonRef, enabled, ready };
|
||||||
|
}
|
||||||
@@ -0,0 +1,935 @@
|
|||||||
|
:root {
|
||||||
|
--c-bg: #f6f7fb;
|
||||||
|
--c-surface: #ffffff;
|
||||||
|
--c-surface-2: #f1f2f9;
|
||||||
|
--c-border: #e4e6ef;
|
||||||
|
--c-border-strong: #d3d6e6;
|
||||||
|
--c-text: #1c2030;
|
||||||
|
--c-text-soft: #5b6275;
|
||||||
|
--c-text-faint: #8b90a3;
|
||||||
|
|
||||||
|
--c-primary: #6366f1;
|
||||||
|
--c-primary-hover: #4f51e0;
|
||||||
|
--c-primary-soft: #eef0ff;
|
||||||
|
--c-primary-text: #ffffff;
|
||||||
|
|
||||||
|
--c-success: #16a34a;
|
||||||
|
--c-success-soft: #e7f6ec;
|
||||||
|
--c-warn: #d97706;
|
||||||
|
--c-warn-soft: #fdf2e3;
|
||||||
|
--c-danger: #dc2626;
|
||||||
|
--c-danger-soft: #fdeaea;
|
||||||
|
--c-info: #0ea5e9;
|
||||||
|
--c-info-soft: #e6f6fd;
|
||||||
|
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-lg: 18px;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(20, 23, 40, 0.06);
|
||||||
|
--shadow: 0 6px 24px rgba(20, 23, 40, 0.08);
|
||||||
|
--shadow-lg: 0 18px 48px rgba(20, 23, 40, 0.16);
|
||||||
|
|
||||||
|
--font: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||||
|
--maxw: 1180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--c-bg);
|
||||||
|
color: var(--c-text);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--c-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4 {
|
||||||
|
margin: 0 0 0.4em;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: -0.2em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.icon-inline {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
.icon-lg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.icon-xl {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
.icon-muted {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
}
|
||||||
|
.icon-primary {
|
||||||
|
color: var(--c-primary);
|
||||||
|
}
|
||||||
|
.icon-success {
|
||||||
|
color: var(--c-success);
|
||||||
|
}
|
||||||
|
.icon-danger {
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
.icon-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.icon-box {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--c-surface-2);
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
.icon-box-lg {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
.icon-box-primary {
|
||||||
|
background: var(--c-primary-soft);
|
||||||
|
color: var(--c-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Layout ---------- */
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 30;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
.navbar-inner {
|
||||||
|
max-width: var(--maxw);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.brand:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.brand-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.brand-logo .icon {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.nav-link:hover {
|
||||||
|
background: var(--c-surface-2);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.nav-link.active {
|
||||||
|
background: var(--c-primary-soft);
|
||||||
|
color: var(--c-primary);
|
||||||
|
}
|
||||||
|
.nav-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.nav-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--c-primary-soft);
|
||||||
|
color: var(--c-primary);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: var(--maxw);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px 64px;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.page-narrow {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.page-header h1 {
|
||||||
|
font-size: 26px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.page-header p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
.page-header-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Cards ---------- */
|
||||||
|
.card {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.card-pad {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
.card-head {
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.card-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.grid-cards {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||||||
|
}
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Buttons ---------- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, transform 0.05s, opacity 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--c-primary);
|
||||||
|
color: var(--c-primary-text);
|
||||||
|
}
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--c-primary-hover);
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
border-color: var(--c-border-strong);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover:not(:disabled) {
|
||||||
|
background: var(--c-surface-2);
|
||||||
|
}
|
||||||
|
.btn-subtle {
|
||||||
|
background: var(--c-surface-2);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.btn-subtle:hover:not(:disabled) {
|
||||||
|
background: var(--c-border);
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--c-danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #b91c1c;
|
||||||
|
}
|
||||||
|
.btn-danger-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--c-danger);
|
||||||
|
border-color: var(--c-danger-soft);
|
||||||
|
}
|
||||||
|
.btn-danger-ghost:hover:not(:disabled) {
|
||||||
|
background: var(--c-danger-soft);
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.btn-lg {
|
||||||
|
padding: 13px 22px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.btn-block {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.btn-icon {
|
||||||
|
padding: 8px;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Forms ---------- */
|
||||||
|
.field {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--c-text);
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
.field-hint {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.field-error {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--c-danger);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.input,
|
||||||
|
.textarea,
|
||||||
|
.select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid var(--c-border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--c-text);
|
||||||
|
background: var(--c-surface);
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.input:focus,
|
||||||
|
.textarea:focus,
|
||||||
|
.select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--c-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--c-primary-soft);
|
||||||
|
}
|
||||||
|
.input.has-error,
|
||||||
|
.textarea.has-error {
|
||||||
|
border-color: var(--c-danger);
|
||||||
|
}
|
||||||
|
.textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 110px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.textarea-mono {
|
||||||
|
font-family: "JetBrains Mono", "Consolas", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.row > * {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
.checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.checkbox input {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
accent-color: var(--c-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Badges ---------- */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--c-surface-2);
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
.badge-primary {
|
||||||
|
background: var(--c-primary-soft);
|
||||||
|
color: var(--c-primary);
|
||||||
|
}
|
||||||
|
.badge-success {
|
||||||
|
background: var(--c-success-soft);
|
||||||
|
color: var(--c-success);
|
||||||
|
}
|
||||||
|
.badge-warn {
|
||||||
|
background: var(--c-warn-soft);
|
||||||
|
color: var(--c-warn);
|
||||||
|
}
|
||||||
|
.badge-danger {
|
||||||
|
background: var(--c-danger-soft);
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
.badge-info {
|
||||||
|
background: var(--c-info-soft);
|
||||||
|
color: var(--c-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Tabs ---------- */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
padding: 11px 16px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
.tab.active {
|
||||||
|
color: var(--c-primary);
|
||||||
|
border-bottom-color: var(--c-primary);
|
||||||
|
}
|
||||||
|
.tab-count {
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--c-surface-2);
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Auth pages ---------- */
|
||||||
|
.auth-wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
.auth-aside {
|
||||||
|
background: linear-gradient(150deg, #4f46e5, #7c3aed 55%, #9333ea);
|
||||||
|
color: #fff;
|
||||||
|
padding: 56px 52px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.auth-aside h2 {
|
||||||
|
font-size: 30px;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
.auth-aside p {
|
||||||
|
color: rgba(255, 255, 255, 0.82);
|
||||||
|
max-width: 420px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.auth-feature {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.auth-feature-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.auth-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
.auth-card h1 {
|
||||||
|
font-size: 25px;
|
||||||
|
}
|
||||||
|
.auth-sub {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
.auth-switch {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 22px;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 20px 0;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.divider::before,
|
||||||
|
.divider::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--c-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 880px) {
|
||||||
|
.auth-wrap {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.auth-aside {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Misc ---------- */
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.template-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border-color: var(--c-border-strong);
|
||||||
|
}
|
||||||
|
.template-card-top {
|
||||||
|
height: 7px;
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
|
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||||
|
}
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.meta-row span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--c-surface);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.list-item-icon {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--c-surface-2);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.list-item-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.list-item-title {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.list-item-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 56px 24px;
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
.empty-state-icon {
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2.5px solid var(--c-border-strong);
|
||||||
|
border-top-color: var(--c-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
.spinner-lg {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-width: 4px;
|
||||||
|
}
|
||||||
|
.spinner-center {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
.spinner-light {
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Modal ---------- */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(20, 23, 40, 0.55);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fade 0.15s ease;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: auto;
|
||||||
|
animation: pop 0.16s ease;
|
||||||
|
}
|
||||||
|
.modal-lg {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
.modal-head {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--c-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.modal-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
.modal-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.modal-foot {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
@keyframes fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes pop {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px) scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Toasts ---------- */
|
||||||
|
.toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 11px;
|
||||||
|
background: var(--c-surface);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
border-left: 4px solid var(--c-text-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 13px 15px;
|
||||||
|
animation: slidein 0.2s ease;
|
||||||
|
}
|
||||||
|
.toast-success {
|
||||||
|
border-left-color: var(--c-success);
|
||||||
|
}
|
||||||
|
.toast-error {
|
||||||
|
border-left-color: var(--c-danger);
|
||||||
|
}
|
||||||
|
.toast-info {
|
||||||
|
border-left-color: var(--c-info);
|
||||||
|
}
|
||||||
|
.toast-icon {
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toast-icon.icon-success {
|
||||||
|
color: var(--c-success);
|
||||||
|
}
|
||||||
|
.toast-icon.icon-error {
|
||||||
|
color: var(--c-danger);
|
||||||
|
}
|
||||||
|
.toast-icon.icon-info {
|
||||||
|
color: var(--c-info);
|
||||||
|
}
|
||||||
|
.toast-content {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.toast-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.toast-msg {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.toast-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
@keyframes slidein {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Utilities ---------- */
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.gap-sm {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.gap {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.mt {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.mt-lg {
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
.mb {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.text-soft {
|
||||||
|
color: var(--c-text-soft);
|
||||||
|
}
|
||||||
|
.text-faint {
|
||||||
|
color: var(--c-text-faint);
|
||||||
|
}
|
||||||
|
.text-sm {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: "JetBrains Mono", "Consolas", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.code-block {
|
||||||
|
background: #1e2030;
|
||||||
|
color: #e4e6ef;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: "JetBrains Mono", "Consolas", monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 420px;
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
height: 8px;
|
||||||
|
background: var(--c-surface-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #6366f1, #8b5cf6);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.progress-bar.warn {
|
||||||
|
background: linear-gradient(90deg, #f59e0b, #f97316);
|
||||||
|
}
|
||||||
|
.progress-bar.danger {
|
||||||
|
background: linear-gradient(90deg, #ef4444, #dc2626);
|
||||||
|
}
|
||||||
|
.divider-line {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--c-border);
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--c-surface-2);
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
.thumb-sm {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--c-border);
|
||||||
|
background: var(--c-surface-2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
|
import { ToastProvider } from "./context/ToastContext";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { createTemplate } from "../api/templates";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { Field, Input, Select, Checkbox } from "../components/ui/Field";
|
||||||
|
import Button from "../components/ui/Button";
|
||||||
|
import { Badge } from "../components/ui/Misc";
|
||||||
|
import { QUESTION_TYPES, DIFFICULTIES } from "../utils/constants";
|
||||||
|
import { totalQuestionsFromProfile } from "../utils/format";
|
||||||
|
|
||||||
|
const emptyType = () => ({
|
||||||
|
type: "multichoice",
|
||||||
|
count: 5,
|
||||||
|
options_count: 4,
|
||||||
|
multiple_correct: false,
|
||||||
|
score: 1,
|
||||||
|
penalty: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function CreateTemplatePage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: "",
|
||||||
|
subject: "",
|
||||||
|
educational_level: "",
|
||||||
|
language: "es",
|
||||||
|
});
|
||||||
|
const [types, setTypes] = useState([emptyType()]);
|
||||||
|
const [settings, setSettings] = useState({
|
||||||
|
shuffle_questions: true,
|
||||||
|
shuffle_answers: true,
|
||||||
|
include_feedback: true,
|
||||||
|
});
|
||||||
|
const [difficulty, setDifficulty] = useState({
|
||||||
|
easy: 2,
|
||||||
|
medium: 3,
|
||||||
|
hard: 0,
|
||||||
|
very_hard: 0,
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const totalDifficulty = useMemo(
|
||||||
|
() => totalQuestionsFromProfile(difficulty),
|
||||||
|
[difficulty]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onField = (e) =>
|
||||||
|
setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
|
||||||
|
|
||||||
|
const updateType = (idx, patch) =>
|
||||||
|
setTypes((prev) => prev.map((t, i) => (i === idx ? { ...t, ...patch } : t)));
|
||||||
|
|
||||||
|
const addType = () => setTypes((prev) => [...prev, emptyType()]);
|
||||||
|
const removeType = (idx) =>
|
||||||
|
setTypes((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const e = {};
|
||||||
|
if (form.title.trim().length < 3) e.title = "Mínimo 3 caracteres.";
|
||||||
|
if (form.subject.trim().length < 2) e.subject = "Mínimo 2 caracteres.";
|
||||||
|
if (form.educational_level.trim().length < 2)
|
||||||
|
e.educational_level = "Mínimo 2 caracteres.";
|
||||||
|
if (types.length === 0) e.types = "Añade al menos un tipo de pregunta.";
|
||||||
|
if (totalDifficulty <= 0)
|
||||||
|
e.difficulty = "Reparte al menos una pregunta entre las dificultades.";
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) {
|
||||||
|
toast.error("Revisa los campos marcados.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
...form,
|
||||||
|
title: form.title.trim(),
|
||||||
|
subject: form.subject.trim(),
|
||||||
|
educational_level: form.educational_level.trim(),
|
||||||
|
settings: {
|
||||||
|
question_types: types.map((t) => ({
|
||||||
|
type: t.type,
|
||||||
|
count: Number(t.count),
|
||||||
|
options_count:
|
||||||
|
t.type === "multichoice" ? Number(t.options_count) : null,
|
||||||
|
multiple_correct:
|
||||||
|
t.type === "multichoice" ? t.multiple_correct : false,
|
||||||
|
score: Number(t.score),
|
||||||
|
penalty: Number(t.penalty),
|
||||||
|
})),
|
||||||
|
...settings,
|
||||||
|
},
|
||||||
|
difficulty_profile: {
|
||||||
|
easy: Number(difficulty.easy) || 0,
|
||||||
|
medium: Number(difficulty.medium) || 0,
|
||||||
|
hard: Number(difficulty.hard) || 0,
|
||||||
|
very_hard: Number(difficulty.very_hard) || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const created = await createTemplate(payload);
|
||||||
|
toast.success("Plantilla creada correctamente.");
|
||||||
|
navigate(`/plantillas/${created.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
if (err.details) {
|
||||||
|
console.warn("Detalles de validación:", err.details);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page page-narrow">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Nuevo examen</h1>
|
||||||
|
<p>Define la estructura. Después podrás subir material y generar preguntas.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} noValidate>
|
||||||
|
{/* Datos generales */}
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-head">
|
||||||
|
<h3>1 · Información general</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<Field label="Título del examen" error={errors.title}>
|
||||||
|
<Input
|
||||||
|
name="title"
|
||||||
|
value={form.title}
|
||||||
|
onChange={onField}
|
||||||
|
placeholder="Ej. Examen Tema 3 — Sistemas Operativos"
|
||||||
|
error={errors.title}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="row">
|
||||||
|
<Field label="Asignatura" error={errors.subject}>
|
||||||
|
<Input
|
||||||
|
name="subject"
|
||||||
|
value={form.subject}
|
||||||
|
onChange={onField}
|
||||||
|
placeholder="Ej. Sistemas Operativos"
|
||||||
|
error={errors.subject}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Nivel educativo" error={errors.educational_level}>
|
||||||
|
<Input
|
||||||
|
name="educational_level"
|
||||||
|
value={form.educational_level}
|
||||||
|
onChange={onField}
|
||||||
|
placeholder="Ej. Ciclo Superior DAM"
|
||||||
|
error={errors.educational_level}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Idioma">
|
||||||
|
<Select name="language" value={form.language} onChange={onField}>
|
||||||
|
<option value="es">Español</option>
|
||||||
|
<option value="en">Inglés</option>
|
||||||
|
<option value="fr">Francés</option>
|
||||||
|
<option value="de">Alemán</option>
|
||||||
|
<option value="it">Italiano</option>
|
||||||
|
<option value="pt">Portugués</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tipos de pregunta */}
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-head">
|
||||||
|
<h3>2 · Tipos de pregunta</h3>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button type="button" variant="subtle" size="sm" onClick={addType}>
|
||||||
|
+ Añadir tipo
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{errors.types && <div className="field-error mb">{errors.types}</div>}
|
||||||
|
{types.map((t, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="card"
|
||||||
|
style={{ padding: 16, marginBottom: 14, background: "var(--c-surface-2)" }}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb">
|
||||||
|
<strong>Bloque {idx + 1}</strong>
|
||||||
|
{types.length > 1 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="danger-ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeType(idx)}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<Field label="Tipo">
|
||||||
|
<Select
|
||||||
|
value={t.type}
|
||||||
|
onChange={(e) => updateType(idx, { type: e.target.value })}
|
||||||
|
>
|
||||||
|
{QUESTION_TYPES.map((q) => (
|
||||||
|
<option key={q.value} value={q.value}>
|
||||||
|
{q.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Nº preguntas">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={200}
|
||||||
|
value={t.count}
|
||||||
|
onChange={(e) => updateType(idx, { count: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<Field label="Puntuación">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.5}
|
||||||
|
value={t.score}
|
||||||
|
onChange={(e) => updateType(idx, { score: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Penalización">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.25}
|
||||||
|
value={t.penalty}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateType(idx, { penalty: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{t.type === "multichoice" && (
|
||||||
|
<div className="row items-center">
|
||||||
|
<Field label="Nº de opciones">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={2}
|
||||||
|
max={8}
|
||||||
|
value={t.options_count}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateType(idx, { options_count: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div style={{ paddingTop: 26 }}>
|
||||||
|
<Checkbox
|
||||||
|
label="Permitir varias respuestas correctas"
|
||||||
|
checked={t.multiple_correct}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateType(idx, { multiple_correct: e.target.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Perfil de dificultad */}
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-head">
|
||||||
|
<h3>3 · Reparto por dificultad</h3>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Badge variant={totalDifficulty > 0 ? "primary" : "danger"}>
|
||||||
|
{totalDifficulty} preguntas
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{errors.difficulty && (
|
||||||
|
<div className="field-error mb">{errors.difficulty}</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-2">
|
||||||
|
{DIFFICULTIES.map((d) => (
|
||||||
|
<Field key={d.value} label={d.label}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={difficulty[d.value]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDifficulty((p) => ({ ...p, [d.value]: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="field-hint">
|
||||||
|
Indica cuántas preguntas quieres de cada nivel. La IA intentará
|
||||||
|
respetar este reparto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Opciones */}
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-head">
|
||||||
|
<h3>4 · Opciones del examen</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body flex" style={{ flexDirection: "column", gap: 14 }}>
|
||||||
|
<Checkbox
|
||||||
|
label="Barajar el orden de las preguntas"
|
||||||
|
checked={settings.shuffle_questions}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, shuffle_questions: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Barajar el orden de las respuestas"
|
||||||
|
checked={settings.shuffle_answers}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, shuffle_answers: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
label="Incluir retroalimentación (feedback) en las preguntas"
|
||||||
|
checked={settings.include_feedback}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, include_feedback: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap justify-between">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => navigate("/")}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="lg" loading={saving}>
|
||||||
|
Crear examen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { listTemplates } from "../api/templates";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { SpinnerCenter } from "../components/ui/Spinner";
|
||||||
|
import Button from "../components/ui/Button";
|
||||||
|
import { Badge, EmptyState } from "../components/ui/Misc";
|
||||||
|
import { formatLastUpdated } from "../utils/format";
|
||||||
|
import Icon from "../components/ui/Icon";
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const toast = useToast();
|
||||||
|
const [templates, setTemplates] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
listTemplates()
|
||||||
|
.then((data) => active && setTemplates(data))
|
||||||
|
.catch((err) => toast.error(err.message))
|
||||||
|
.finally(() => active && setLoading(false));
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <SpinnerCenter label="Cargando tus exámenes…" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>Mis exámenes</h1>
|
||||||
|
<p>Gestiona tus plantillas de examen y genera preguntas con IA.</p>
|
||||||
|
</div>
|
||||||
|
<div className="page-header-actions">
|
||||||
|
<Button onClick={() => navigate("/plantillas/nueva")}>
|
||||||
|
<Icon name="plus" size={16} className="icon-inline" />
|
||||||
|
Nuevo examen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<div className="card">
|
||||||
|
<EmptyState
|
||||||
|
icon="folder"
|
||||||
|
title="Aún no tienes exámenes"
|
||||||
|
message="Crea tu primera plantilla para empezar a generar preguntas con IA."
|
||||||
|
action={
|
||||||
|
<Button onClick={() => navigate("/plantillas/nueva")}>
|
||||||
|
Crear mi primer examen
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cards">
|
||||||
|
{templates.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className="card template-card"
|
||||||
|
onClick={() => navigate(`/plantillas/${t.id}`)}
|
||||||
|
>
|
||||||
|
<div className="template-card-top" />
|
||||||
|
<div className="card-pad">
|
||||||
|
<div className="flex justify-between items-center mb">
|
||||||
|
<Badge variant="primary">{t.subject}</Badge>
|
||||||
|
<Badge variant={t.question_count > 0 ? "success" : undefined}>
|
||||||
|
{t.question_count} preg.
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h3 style={{ marginBottom: 6 }}>{t.title}</h3>
|
||||||
|
<div className="meta-row mt">
|
||||||
|
<span className="icon-wrap">
|
||||||
|
<Icon name="graduation" size={14} className="icon-inline" />
|
||||||
|
{t.educational_level}
|
||||||
|
</span>
|
||||||
|
<span className="icon-wrap">
|
||||||
|
<Icon name="globe" size={14} className="icon-inline" />
|
||||||
|
{t.language?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="divider-line" />
|
||||||
|
<div className="text-sm text-faint">
|
||||||
|
{formatLastUpdated(t.updated_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { useGoogleSignIn } from "../hooks/useGoogleSignIn";
|
||||||
|
import AuthLayout from "../components/AuthLayout";
|
||||||
|
import { Field, Input } from "../components/ui/Field";
|
||||||
|
import Button from "../components/ui/Button";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { login, loginWithGoogle, isAuthenticated } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const from = location.state?.from?.pathname || "/";
|
||||||
|
|
||||||
|
const [form, setForm] = useState({ email: "", password: "" });
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) navigate(from, { replace: true });
|
||||||
|
}, [isAuthenticated, from, navigate]);
|
||||||
|
|
||||||
|
const onChange = (e) =>
|
||||||
|
setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const e = {};
|
||||||
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email))
|
||||||
|
e.email = "Introduce un email válido.";
|
||||||
|
if (!form.password) e.password = "La contraseña es obligatoria.";
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(form);
|
||||||
|
toast.success("Sesión iniciada correctamente.");
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGoogle = async (idToken) => {
|
||||||
|
try {
|
||||||
|
await loginWithGoogle(idToken);
|
||||||
|
toast.success("Sesión iniciada con Google.");
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { buttonRef, enabled: googleEnabled } = useGoogleSignIn(onGoogle);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<h1>Bienvenido de nuevo</h1>
|
||||||
|
<p className="auth-sub">Inicia sesión para gestionar tus exámenes.</p>
|
||||||
|
|
||||||
|
<form onSubmit={submit} noValidate>
|
||||||
|
<Field label="Email" error={errors.email} htmlFor="email">
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="tu@correo.com"
|
||||||
|
value={form.email}
|
||||||
|
onChange={onChange}
|
||||||
|
error={errors.email}
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Contraseña" error={errors.password} htmlFor="password">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={form.password}
|
||||||
|
onChange={onChange}
|
||||||
|
error={errors.password}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" block size="lg" loading={loading}>
|
||||||
|
Iniciar sesión
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{googleEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="divider">o continúa con</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||||
|
<div ref={buttonRef} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="auth-switch">
|
||||||
|
¿No tienes cuenta? <Link to="/registro">Crea una gratis</Link>
|
||||||
|
</p>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import Icon from "../components/ui/Icon";
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<div className="page page-narrow text-center" style={{ paddingTop: 90 }}>
|
||||||
|
<div className="icon-wrap icon-box icon-box-lg" style={{ margin: "0 auto 16px" }}>
|
||||||
|
<Icon name="compass" size={32} className="icon-muted" />
|
||||||
|
</div>
|
||||||
|
<h1 style={{ fontSize: 30 }}>Página no encontrada</h1>
|
||||||
|
<p className="text-soft">
|
||||||
|
La página que buscas no existe o ha sido movida.
|
||||||
|
</p>
|
||||||
|
<Link to="/" className="btn btn-primary mt">
|
||||||
|
Volver al inicio
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { useGoogleSignIn } from "../hooks/useGoogleSignIn";
|
||||||
|
import AuthLayout from "../components/AuthLayout";
|
||||||
|
import { Field, Input } from "../components/ui/Field";
|
||||||
|
import Button from "../components/ui/Button";
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const { register, loginWithGoogle } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
full_name: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
confirm: "",
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const onChange = (e) =>
|
||||||
|
setForm((f) => ({ ...f, [e.target.name]: e.target.value }));
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const e = {};
|
||||||
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(form.email))
|
||||||
|
e.email = "Introduce un email válido.";
|
||||||
|
if (form.password.length < 8)
|
||||||
|
e.password = "La contraseña debe tener al menos 8 caracteres.";
|
||||||
|
if (form.password !== form.confirm)
|
||||||
|
e.confirm = "Las contraseñas no coinciden.";
|
||||||
|
setErrors(e);
|
||||||
|
return Object.keys(e).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await register({
|
||||||
|
email: form.email,
|
||||||
|
password: form.password,
|
||||||
|
full_name: form.full_name,
|
||||||
|
});
|
||||||
|
toast.success("Cuenta creada. ¡Bienvenido!");
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGoogle = async (idToken) => {
|
||||||
|
try {
|
||||||
|
await loginWithGoogle(idToken);
|
||||||
|
toast.success("Cuenta vinculada con Google.");
|
||||||
|
navigate("/", { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { buttonRef, enabled: googleEnabled } = useGoogleSignIn(onGoogle);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<h1>Crea tu cuenta</h1>
|
||||||
|
<p className="auth-sub">Empieza a generar exámenes en segundos.</p>
|
||||||
|
|
||||||
|
<form onSubmit={submit} noValidate>
|
||||||
|
<Field label="Nombre (opcional)" htmlFor="full_name">
|
||||||
|
<Input
|
||||||
|
id="full_name"
|
||||||
|
name="full_name"
|
||||||
|
placeholder="Tu nombre"
|
||||||
|
value={form.full_name}
|
||||||
|
onChange={onChange}
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Email" error={errors.email} htmlFor="email">
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="tu@correo.com"
|
||||||
|
value={form.email}
|
||||||
|
onChange={onChange}
|
||||||
|
error={errors.email}
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Contraseña"
|
||||||
|
error={errors.password}
|
||||||
|
hint="Mínimo 8 caracteres."
|
||||||
|
htmlFor="password"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={form.password}
|
||||||
|
onChange={onChange}
|
||||||
|
error={errors.password}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Repite la contraseña" error={errors.confirm} htmlFor="confirm">
|
||||||
|
<Input
|
||||||
|
id="confirm"
|
||||||
|
name="confirm"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={form.confirm}
|
||||||
|
onChange={onChange}
|
||||||
|
error={errors.confirm}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" block size="lg" loading={loading}>
|
||||||
|
Crear cuenta
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{googleEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="divider">o regístrate con</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||||
|
<div ref={buttonRef} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="auth-switch">
|
||||||
|
¿Ya tienes cuenta? <Link to="/login">Inicia sesión</Link>
|
||||||
|
</p>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
getTemplate,
|
||||||
|
getTemplateStorage,
|
||||||
|
listQuestions,
|
||||||
|
} from "../api/templates";
|
||||||
|
import { listMaterials } from "../api/materials";
|
||||||
|
import { listImages } from "../api/images";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { SpinnerCenter } from "../components/ui/Spinner";
|
||||||
|
import { Badge } from "../components/ui/Misc";
|
||||||
|
import OverviewTab from "./template/OverviewTab";
|
||||||
|
import MaterialsTab from "./template/MaterialsTab";
|
||||||
|
import ImagesTab from "./template/ImagesTab";
|
||||||
|
import GenerateTab from "./template/GenerateTab";
|
||||||
|
import QuestionsTab from "./template/QuestionsTab";
|
||||||
|
import ExportTab from "./template/ExportTab";
|
||||||
|
import Icon from "../components/ui/Icon";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: "overview", label: "Resumen", icon: "clipboard" },
|
||||||
|
{ id: "materials", label: "Material IA", icon: "book" },
|
||||||
|
{ id: "images", label: "Imágenes", icon: "image" },
|
||||||
|
{ id: "generate", label: "Generar", icon: "sparkles" },
|
||||||
|
{ id: "questions", label: "Preguntas", icon: "help" },
|
||||||
|
{ id: "export", label: "Exportar", icon: "upload" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TemplateDetailPage() {
|
||||||
|
const { templateId } = useParams();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [tab, setTab] = useState("overview");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [notFound, setNotFound] = useState(false);
|
||||||
|
|
||||||
|
const [template, setTemplate] = useState(null);
|
||||||
|
const [storage, setStorage] = useState(null);
|
||||||
|
const [materials, setMaterials] = useState([]);
|
||||||
|
const [images, setImages] = useState([]);
|
||||||
|
const [questions, setQuestions] = useState([]);
|
||||||
|
|
||||||
|
const reloadStorage = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setStorage(await getTemplateStorage(templateId));
|
||||||
|
} catch {
|
||||||
|
/* silencioso */
|
||||||
|
}
|
||||||
|
}, [templateId]);
|
||||||
|
|
||||||
|
const reloadMaterials = useCallback(async () => {
|
||||||
|
setMaterials(await listMaterials(templateId));
|
||||||
|
}, [templateId]);
|
||||||
|
|
||||||
|
const reloadImages = useCallback(async () => {
|
||||||
|
setImages(await listImages(templateId));
|
||||||
|
}, [templateId]);
|
||||||
|
|
||||||
|
const reloadQuestions = useCallback(async () => {
|
||||||
|
setQuestions(await listQuestions(templateId));
|
||||||
|
}, [templateId]);
|
||||||
|
|
||||||
|
const reloadTemplate = useCallback(async () => {
|
||||||
|
setTemplate(await getTemplate(templateId));
|
||||||
|
}, [templateId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
getTemplate(templateId),
|
||||||
|
getTemplateStorage(templateId).catch(() => null),
|
||||||
|
listMaterials(templateId).catch(() => []),
|
||||||
|
listImages(templateId).catch(() => []),
|
||||||
|
listQuestions(templateId).catch(() => []),
|
||||||
|
])
|
||||||
|
.then(([tpl, stg, mats, imgs, qs]) => {
|
||||||
|
if (!active) return;
|
||||||
|
setTemplate(tpl);
|
||||||
|
setStorage(stg);
|
||||||
|
setMaterials(mats);
|
||||||
|
setImages(imgs);
|
||||||
|
setQuestions(qs);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!active) return;
|
||||||
|
if (err.status === 404 || err.status === 403) setNotFound(true);
|
||||||
|
else toast.error(err.message);
|
||||||
|
})
|
||||||
|
.finally(() => active && setLoading(false));
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [templateId]);
|
||||||
|
|
||||||
|
if (loading) return <SpinnerCenter label="Cargando examen…" />;
|
||||||
|
|
||||||
|
if (notFound) {
|
||||||
|
return (
|
||||||
|
<div className="page page-narrow text-center" style={{ paddingTop: 70 }}>
|
||||||
|
<div className="icon-wrap icon-box icon-box-lg" style={{ margin: "0 auto 16px" }}>
|
||||||
|
<Icon name="lock" size={32} className="icon-muted" />
|
||||||
|
</div>
|
||||||
|
<h1>Examen no disponible</h1>
|
||||||
|
<p className="text-soft">
|
||||||
|
No existe o no tienes permiso para verlo.
|
||||||
|
</p>
|
||||||
|
<Link to="/" className="btn btn-primary mt">
|
||||||
|
Volver a mis exámenes
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shared = {
|
||||||
|
templateId,
|
||||||
|
template,
|
||||||
|
storage,
|
||||||
|
materials,
|
||||||
|
images,
|
||||||
|
questions,
|
||||||
|
reloadStorage,
|
||||||
|
reloadMaterials,
|
||||||
|
reloadImages,
|
||||||
|
reloadQuestions,
|
||||||
|
reloadTemplate,
|
||||||
|
goToTab: setTab,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="text-sm text-faint mb">
|
||||||
|
<Link to="/">Mis exámenes</Link> / {template.title}
|
||||||
|
</div>
|
||||||
|
<div className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>{template.title}</h1>
|
||||||
|
<div className="meta-row mt">
|
||||||
|
<Badge variant="primary">{template.subject}</Badge>
|
||||||
|
<span className="icon-wrap">
|
||||||
|
<Icon name="graduation" size={14} className="icon-inline" />
|
||||||
|
{template.educational_level}
|
||||||
|
</span>
|
||||||
|
<span className="icon-wrap">
|
||||||
|
<Icon name="globe" size={14} className="icon-inline" />
|
||||||
|
{template.language?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<Badge variant={questions.length > 0 ? "success" : undefined}>
|
||||||
|
{questions.length} preguntas
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="tabs">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
className={`tab ${tab === t.id ? "active" : ""}`}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
>
|
||||||
|
<Icon name={t.icon} size={16} />
|
||||||
|
{t.label}
|
||||||
|
{t.id === "materials" && materials.length > 0 && (
|
||||||
|
<span className="tab-count">{materials.length}</span>
|
||||||
|
)}
|
||||||
|
{t.id === "images" && images.length > 0 && (
|
||||||
|
<span className="tab-count">{images.length}</span>
|
||||||
|
)}
|
||||||
|
{t.id === "questions" && questions.length > 0 && (
|
||||||
|
<span className="tab-count">{questions.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "overview" && <OverviewTab {...shared} />}
|
||||||
|
{tab === "materials" && <MaterialsTab {...shared} />}
|
||||||
|
{tab === "images" && <ImagesTab {...shared} />}
|
||||||
|
{tab === "generate" && <GenerateTab {...shared} />}
|
||||||
|
{tab === "questions" && <QuestionsTab {...shared} />}
|
||||||
|
{tab === "export" && <ExportTab {...shared} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { fetchExport, downloadString } from "../../api/exports";
|
||||||
|
import { useToast } from "../../context/ToastContext";
|
||||||
|
import Button from "../../components/ui/Button";
|
||||||
|
import { EmptyState } from "../../components/ui/Misc";
|
||||||
|
import Icon from "../../components/ui/Icon";
|
||||||
|
|
||||||
|
const FORMATS = [
|
||||||
|
{
|
||||||
|
id: "xml",
|
||||||
|
title: "Moodle XML",
|
||||||
|
icon: "moodle",
|
||||||
|
desc: "Importable directamente en un banco de preguntas de Moodle. Incluye imágenes embebidas.",
|
||||||
|
mime: "application/xml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "txt",
|
||||||
|
title: "Texto plano",
|
||||||
|
icon: "file",
|
||||||
|
desc: "Listado simple de enunciados y respuestas para revisión rápida.",
|
||||||
|
mime: "text/plain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "json",
|
||||||
|
title: "JSON",
|
||||||
|
icon: "document",
|
||||||
|
desc: "Estructura completa de las preguntas para integraciones o copias de seguridad.",
|
||||||
|
mime: "application/json",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ExportTab({ templateId, template, questions }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [loadingFormat, setLoadingFormat] = useState(null);
|
||||||
|
const [preview, setPreview] = useState(null);
|
||||||
|
|
||||||
|
const slug = (template.title || "examen")
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "")
|
||||||
|
.slice(0, 50);
|
||||||
|
|
||||||
|
const run = async (fmt, { download }) => {
|
||||||
|
setLoadingFormat(fmt.id);
|
||||||
|
try {
|
||||||
|
const content = await fetchExport(templateId, fmt.id);
|
||||||
|
if (download) {
|
||||||
|
downloadString(content, `${slug}.${fmt.id}`, fmt.mime);
|
||||||
|
toast.success(`Examen exportado como ${fmt.title}.`);
|
||||||
|
} else {
|
||||||
|
setPreview({ fmt, content });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoadingFormat(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (questions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<EmptyState
|
||||||
|
icon="upload"
|
||||||
|
title="Nada que exportar todavía"
|
||||||
|
message="Genera preguntas antes de exportar el examen."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-soft mb">
|
||||||
|
Tu examen tiene <strong>{questions.length} preguntas</strong>. Elige un
|
||||||
|
formato para descargarlo o previsualizarlo.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cards">
|
||||||
|
{FORMATS.map((fmt) => (
|
||||||
|
<div key={fmt.id} className="card card-pad">
|
||||||
|
<div className="icon-wrap icon-box icon-box-lg" style={{ marginBottom: 8 }}>
|
||||||
|
<Icon name={fmt.icon} size={28} className="icon-primary" />
|
||||||
|
</div>
|
||||||
|
<h3 style={{ margin: "8px 0 4px" }}>{fmt.title}</h3>
|
||||||
|
<p className="text-sm text-soft" style={{ minHeight: 60 }}>
|
||||||
|
{fmt.desc}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-sm">
|
||||||
|
<Button
|
||||||
|
onClick={() => run(fmt, { download: true })}
|
||||||
|
loading={loadingFormat === fmt.id}
|
||||||
|
>
|
||||||
|
Descargar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => run(fmt, { download: false })}
|
||||||
|
disabled={loadingFormat === fmt.id}
|
||||||
|
>
|
||||||
|
Previsualizar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className="card mt-lg">
|
||||||
|
<div className="card-head">
|
||||||
|
<h3>Vista previa · {preview.fmt.title}</h3>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
onClick={() =>
|
||||||
|
downloadString(
|
||||||
|
preview.content,
|
||||||
|
`${slug}.${preview.fmt.id}`,
|
||||||
|
preview.fmt.mime
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Descargar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setPreview(null)}>
|
||||||
|
Cerrar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="code-block">{preview.content}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { buildPrompt, generateExam, parseOutput } from "../../api/generation";
|
||||||
|
import { useToast } from "../../context/ToastContext";
|
||||||
|
import Button from "../../components/ui/Button";
|
||||||
|
import { Field, Textarea, Select, Checkbox } from "../../components/ui/Field";
|
||||||
|
import { Badge, EmptyState } from "../../components/ui/Misc";
|
||||||
|
import QuestionCard from "../../components/QuestionCard";
|
||||||
|
import { totalQuestionsFromProfile } from "../../utils/format";
|
||||||
|
import Icon from "../../components/ui/Icon";
|
||||||
|
|
||||||
|
const MODES = [
|
||||||
|
{ id: "auto", label: "Generación automática", icon: "sparkles" },
|
||||||
|
{ id: "prompt", label: "Solo prompt", icon: "document" },
|
||||||
|
{ id: "parse", label: "Pegar respuesta IA", icon: "download" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function GenerateTab({
|
||||||
|
templateId,
|
||||||
|
template,
|
||||||
|
materials,
|
||||||
|
reloadQuestions,
|
||||||
|
reloadTemplate,
|
||||||
|
goToTab,
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [mode, setMode] = useState("auto");
|
||||||
|
const [topic, setTopic] = useState("");
|
||||||
|
const [useAllMaterials, setUseAllMaterials] = useState(true);
|
||||||
|
const [selectedMaterials, setSelectedMaterials] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [rawOutput, setRawOutput] = useState("");
|
||||||
|
const [inputFormat, setInputFormat] = useState("json");
|
||||||
|
const [generated, setGenerated] = useState([]);
|
||||||
|
|
||||||
|
const processedMaterials = materials.filter((m) => m.status === "processed");
|
||||||
|
const expectedTotal = totalQuestionsFromProfile(template.difficulty_profile);
|
||||||
|
|
||||||
|
const materialIds = useAllMaterials ? null : selectedMaterials;
|
||||||
|
|
||||||
|
const toggleMaterial = (id) =>
|
||||||
|
setSelectedMaterials((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onBuildPrompt = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await buildPrompt(templateId, {
|
||||||
|
topic_prompt: topic,
|
||||||
|
material_ids: materialIds,
|
||||||
|
});
|
||||||
|
setPrompt(res.prompt);
|
||||||
|
toast.success("Prompt generado. Cópialo en tu LLM preferido.");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGenerate = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await generateExam({
|
||||||
|
template_id: templateId,
|
||||||
|
topic_prompt: topic,
|
||||||
|
material_ids: materialIds,
|
||||||
|
});
|
||||||
|
setGenerated(res.questions || []);
|
||||||
|
await Promise.all([reloadQuestions(), reloadTemplate()]);
|
||||||
|
toast.success(`Se generaron ${res.questions?.length || 0} preguntas.`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onParse = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await parseOutput({
|
||||||
|
template_id: templateId,
|
||||||
|
raw_output: rawOutput,
|
||||||
|
input_format: inputFormat,
|
||||||
|
});
|
||||||
|
setGenerated(res.questions || []);
|
||||||
|
await Promise.all([reloadQuestions(), reloadTemplate()]);
|
||||||
|
toast.success(`Se importaron ${res.questions?.length || 0} preguntas.`);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyPrompt = () => {
|
||||||
|
navigator.clipboard?.writeText(prompt);
|
||||||
|
toast.info("Prompt copiado al portapapeles.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const topicTooShort = topic.trim().length < 5;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: "1fr 340px" }}>
|
||||||
|
<div>
|
||||||
|
<div className="tabs" style={{ marginBottom: 18 }}>
|
||||||
|
{MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
className={`tab ${mode === m.id ? "active" : ""}`}
|
||||||
|
onClick={() => setMode(m.id)}
|
||||||
|
>
|
||||||
|
<Icon name={m.icon} size={16} />
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode !== "parse" && (
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-body">
|
||||||
|
<Field
|
||||||
|
label="Tema / instrucciones para la IA"
|
||||||
|
hint="Describe el contenido o enfoque del examen (mínimo 5 caracteres)."
|
||||||
|
error={topicTooShort && topic.length > 0 ? "Escribe al menos 5 caracteres." : null}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={topic}
|
||||||
|
onChange={(e) => setTopic(e.target.value)}
|
||||||
|
placeholder="Ej. Genera preguntas sobre la gestión de procesos y planificación de CPU del Tema 3."
|
||||||
|
maxLength={4000}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{processedMaterials.length > 0 && (
|
||||||
|
<Field label="Material de contexto">
|
||||||
|
<Checkbox
|
||||||
|
label={`Usar todo el material procesado (${processedMaterials.length})`}
|
||||||
|
checked={useAllMaterials}
|
||||||
|
onChange={(e) => setUseAllMaterials(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{!useAllMaterials && (
|
||||||
|
<div className="mt flex" style={{ flexDirection: "column", gap: 8 }}>
|
||||||
|
{processedMaterials.map((m) => (
|
||||||
|
<Checkbox
|
||||||
|
key={m.id}
|
||||||
|
label={m.original_filename}
|
||||||
|
checked={selectedMaterials.includes(m.id)}
|
||||||
|
onChange={() => toggleMaterial(m.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap mt">
|
||||||
|
{mode === "auto" ? (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={onGenerate}
|
||||||
|
loading={loading}
|
||||||
|
disabled={topicTooShort}
|
||||||
|
>
|
||||||
|
<Icon name="sparkles" size={16} className="icon-inline" />
|
||||||
|
Generar preguntas
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={onBuildPrompt}
|
||||||
|
loading={loading}
|
||||||
|
disabled={topicTooShort}
|
||||||
|
>
|
||||||
|
<Icon name="document" size={16} className="icon-inline" />
|
||||||
|
Construir prompt
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "prompt" && prompt && (
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-head">
|
||||||
|
<h3>Prompt generado</h3>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button size="sm" variant="subtle" onClick={copyPrompt}>
|
||||||
|
Copiar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="code-block">{prompt}</div>
|
||||||
|
<p className="field-hint">
|
||||||
|
Pega este prompt en tu LLM, copia su respuesta JSON y vuelve con
|
||||||
|
el modo <strong>“Pegar respuesta IA”</strong> para importar las
|
||||||
|
preguntas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "parse" && (
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-body">
|
||||||
|
<Field label="Formato de entrada">
|
||||||
|
<Select
|
||||||
|
value={inputFormat}
|
||||||
|
onChange={(e) => setInputFormat(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="json">JSON (recomendado)</option>
|
||||||
|
<option value="txt">Texto plano</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Respuesta de la IA"
|
||||||
|
hint="Pega aquí la salida del LLM."
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
mono
|
||||||
|
value={rawOutput}
|
||||||
|
onChange={(e) => setRawOutput(e.target.value)}
|
||||||
|
placeholder='{ "questions": [ ... ] }'
|
||||||
|
style={{ minHeight: 220 }}
|
||||||
|
maxLength={200000}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={onParse}
|
||||||
|
loading={loading}
|
||||||
|
disabled={rawOutput.trim().length < 5}
|
||||||
|
>
|
||||||
|
<Icon name="download" size={16} className="icon-inline" />
|
||||||
|
Importar preguntas
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{generated.length > 0 && (
|
||||||
|
<div className="mt-lg">
|
||||||
|
<div className="flex justify-between items-center mb">
|
||||||
|
<h3 style={{ margin: 0 }}>
|
||||||
|
Resultado ({generated.length} preguntas)
|
||||||
|
</h3>
|
||||||
|
<Button variant="subtle" size="sm" onClick={() => goToTab("questions")}>
|
||||||
|
Ver todas las preguntas →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{generated.map((q, i) => (
|
||||||
|
<QuestionCard key={q.id || i} question={q} index={i + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-head">
|
||||||
|
<h3>Resumen del objetivo</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body flex" style={{ flexDirection: "column", gap: 10 }}>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-soft">Preguntas objetivo</span>
|
||||||
|
<Badge variant="primary">{expectedTotal}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-soft">Material procesado</span>
|
||||||
|
<Badge variant={processedMaterials.length ? "success" : undefined}>
|
||||||
|
{processedMaterials.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-sm text-soft">
|
||||||
|
<strong>¿Cómo funciona?</strong>
|
||||||
|
<ul style={{ paddingLeft: 18, margin: "8px 0 0" }}>
|
||||||
|
<li>
|
||||||
|
<strong>Automática:</strong> el backend llama al LLM y guarda las
|
||||||
|
preguntas.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Solo prompt:</strong> obtienes el prompt para usarlo en
|
||||||
|
otro LLM.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Pegar respuesta:</strong> importas la salida JSON/TXT de
|
||||||
|
la IA.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { uploadImage, deleteImage } from "../../api/images";
|
||||||
|
import { useToast } from "../../context/ToastContext";
|
||||||
|
import FileDropzone from "../../components/FileDropzone";
|
||||||
|
import AuthImage from "../../components/AuthImage";
|
||||||
|
import StorageBar from "../../components/StorageBar";
|
||||||
|
import Button from "../../components/ui/Button";
|
||||||
|
import { EmptyState } from "../../components/ui/Misc";
|
||||||
|
import { ConfirmDialog } from "../../components/ui/Modal";
|
||||||
|
import Modal from "../../components/ui/Modal";
|
||||||
|
import { Field, Input } from "../../components/ui/Field";
|
||||||
|
import { IMAGE_ACCEPT } from "../../utils/constants";
|
||||||
|
import { formatBytes } from "../../utils/format";
|
||||||
|
|
||||||
|
export default function ImagesTab({
|
||||||
|
templateId,
|
||||||
|
images,
|
||||||
|
storage,
|
||||||
|
reloadImages,
|
||||||
|
reloadStorage,
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [pendingFile, setPendingFile] = useState(null);
|
||||||
|
const [caption, setCaption] = useState("");
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [toDelete, setToDelete] = useState(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleSelected = (file) => {
|
||||||
|
setPendingFile(file);
|
||||||
|
setCaption("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!pendingFile) return;
|
||||||
|
setUploading(true);
|
||||||
|
setProgress(0);
|
||||||
|
try {
|
||||||
|
await uploadImage(templateId, pendingFile, caption, setProgress);
|
||||||
|
await Promise.all([reloadImages(), reloadStorage()]);
|
||||||
|
toast.success("Imagen subida correctamente.");
|
||||||
|
setPendingFile(null);
|
||||||
|
setCaption("");
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
setProgress(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteImage(templateId, toDelete.id);
|
||||||
|
await Promise.all([reloadImages(), reloadStorage()]);
|
||||||
|
toast.success("Imagen eliminada.");
|
||||||
|
setToDelete(null);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: "1fr 320px" }}>
|
||||||
|
<div>
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-body">
|
||||||
|
<FileDropzone
|
||||||
|
accept={IMAGE_ACCEPT}
|
||||||
|
icon="image"
|
||||||
|
hint="PNG, JPG, WEBP o GIF. Se mostrarán dentro del examen (no se les extrae texto)."
|
||||||
|
onFile={handleSelected}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{images.length === 0 ? (
|
||||||
|
<div className="card">
|
||||||
|
<EmptyState
|
||||||
|
icon="image"
|
||||||
|
title="Sin imágenes"
|
||||||
|
message="Sube imágenes para crear preguntas visuales y enlazarlas en la pestaña Preguntas."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cards">
|
||||||
|
{images.map((img) => (
|
||||||
|
<div key={img.id} className="card card-pad">
|
||||||
|
<AuthImage imageId={img.id} alt={img.original_filename} />
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<div className="list-item-title">{img.original_filename}</div>
|
||||||
|
<div className="text-sm text-faint">
|
||||||
|
{formatBytes(img.size_bytes)}
|
||||||
|
{img.caption ? ` · ${img.caption}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="danger-ghost"
|
||||||
|
size="sm"
|
||||||
|
className="mt"
|
||||||
|
onClick={() => setToDelete(img)}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-head">
|
||||||
|
<h3>Espacio</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<StorageBar storage={storage} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-sm text-soft">
|
||||||
|
<strong>Consejo</strong>
|
||||||
|
<p style={{ marginBottom: 0 }}>
|
||||||
|
Añade una descripción a cada imagen. La IA la usa para decidir a
|
||||||
|
qué pregunta corresponde cada imagen al generar el examen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal de caption antes de subir */}
|
||||||
|
<Modal
|
||||||
|
open={!!pendingFile}
|
||||||
|
onClose={() => !uploading && setPendingFile(null)}
|
||||||
|
title="Subir imagen"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setPendingFile(null)}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleUpload} loading={uploading}>
|
||||||
|
Subir imagen
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-soft text-sm">
|
||||||
|
Archivo: <strong>{pendingFile?.name}</strong> (
|
||||||
|
{formatBytes(pendingFile?.size)})
|
||||||
|
</p>
|
||||||
|
<Field
|
||||||
|
label="Descripción / pie de imagen (opcional)"
|
||||||
|
hint="Ayuda a la IA a asociar la imagen con la pregunta correcta."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={caption}
|
||||||
|
onChange={(e) => setCaption(e.target.value)}
|
||||||
|
placeholder="Ej. Diagrama del ciclo del agua"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{uploading && (
|
||||||
|
<div className="progress mt">
|
||||||
|
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!toDelete}
|
||||||
|
title="Eliminar imagen"
|
||||||
|
message={`¿Eliminar "${toDelete?.original_filename}"? Se desvinculará de cualquier pregunta que la use.`}
|
||||||
|
confirmLabel="Eliminar"
|
||||||
|
danger
|
||||||
|
loading={deleting}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onClose={() => setToDelete(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { uploadMaterial, deleteMaterial } from "../../api/materials";
|
||||||
|
import { useToast } from "../../context/ToastContext";
|
||||||
|
import FileDropzone from "../../components/FileDropzone";
|
||||||
|
import StorageBar from "../../components/StorageBar";
|
||||||
|
import Button from "../../components/ui/Button";
|
||||||
|
import { Badge, EmptyState } from "../../components/ui/Misc";
|
||||||
|
import { ConfirmDialog } from "../../components/ui/Modal";
|
||||||
|
import Spinner from "../../components/ui/Spinner";
|
||||||
|
import { MATERIAL_ACCEPT } from "../../utils/constants";
|
||||||
|
import { formatBytes, formatDate } from "../../utils/format";
|
||||||
|
import Icon from "../../components/ui/Icon";
|
||||||
|
|
||||||
|
const STATUS_BADGE = {
|
||||||
|
processed: { variant: "success", label: "Procesado" },
|
||||||
|
pending: { variant: "warn", label: "Pendiente" },
|
||||||
|
failed: { variant: "danger", label: "Error de extracción" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MaterialsTab({
|
||||||
|
templateId,
|
||||||
|
materials,
|
||||||
|
storage,
|
||||||
|
reloadMaterials,
|
||||||
|
reloadStorage,
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [toDelete, setToDelete] = useState(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const handleUpload = async (file) => {
|
||||||
|
setUploading(true);
|
||||||
|
setProgress(0);
|
||||||
|
try {
|
||||||
|
const res = await uploadMaterial(templateId, file, setProgress);
|
||||||
|
await Promise.all([reloadMaterials(), reloadStorage()]);
|
||||||
|
if (res.material?.status === "failed") {
|
||||||
|
toast.info(
|
||||||
|
"El archivo se subió pero no se pudo extraer texto. Aún cuenta para el contexto si lo reintentas con otro formato.",
|
||||||
|
"Subido con avisos"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.success("Material subido y procesado.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
setProgress(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteMaterial(templateId, toDelete.id);
|
||||||
|
await Promise.all([reloadMaterials(), reloadStorage()]);
|
||||||
|
toast.success("Material eliminado.");
|
||||||
|
setToDelete(null);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: "1fr 320px" }}>
|
||||||
|
<div>
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-body">
|
||||||
|
{uploading ? (
|
||||||
|
<div className="text-center" style={{ padding: 24 }}>
|
||||||
|
<Spinner large />
|
||||||
|
<p className="text-soft mt">Subiendo y procesando… {progress}%</p>
|
||||||
|
<div className="progress mt">
|
||||||
|
<div className="progress-bar" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FileDropzone
|
||||||
|
accept={MATERIAL_ACCEPT}
|
||||||
|
icon="book"
|
||||||
|
hint="PDF, DOCX, TXT, MD o imágenes (se extrae el texto, también por OCR)."
|
||||||
|
onFile={handleUpload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{materials.length === 0 ? (
|
||||||
|
<div className="card">
|
||||||
|
<EmptyState
|
||||||
|
icon="inbox"
|
||||||
|
title="Sin material todavía"
|
||||||
|
message="Sube documentos para que la IA genere preguntas basadas en su contenido."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
materials.map((m) => {
|
||||||
|
const badge = STATUS_BADGE[m.status] || STATUS_BADGE.pending;
|
||||||
|
return (
|
||||||
|
<div key={m.id} className="list-item">
|
||||||
|
<div className="list-item-icon icon-wrap icon-box">
|
||||||
|
<Icon name="file" size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="list-item-main">
|
||||||
|
<div className="list-item-title">{m.original_filename}</div>
|
||||||
|
<div className="list-item-sub">
|
||||||
|
{formatBytes(m.size_bytes)} · {formatDate(m.created_at)}
|
||||||
|
</div>
|
||||||
|
{m.status === "failed" && m.error_message && (
|
||||||
|
<div className="field-error">{m.error_message}</div>
|
||||||
|
)}
|
||||||
|
{m.text_preview && (
|
||||||
|
<div
|
||||||
|
className="text-sm text-faint"
|
||||||
|
style={{
|
||||||
|
marginTop: 6,
|
||||||
|
maxHeight: 40,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
“{m.text_preview}”
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-sm items-center" style={{ flex: "none" }}>
|
||||||
|
<Badge variant={badge.variant}>{badge.label}</Badge>
|
||||||
|
<Button
|
||||||
|
variant="danger-ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setToDelete(m)}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-head">
|
||||||
|
<h3>Espacio</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<StorageBar storage={storage} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-sm text-soft">
|
||||||
|
<strong>¿Para qué sirve?</strong>
|
||||||
|
<p style={{ marginBottom: 0 }}>
|
||||||
|
El texto de estos archivos se usa como contexto en el prompt de la
|
||||||
|
IA. No se muestran en el examen; para imágenes visibles usa la
|
||||||
|
pestaña <strong>Imágenes</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!toDelete}
|
||||||
|
title="Eliminar material"
|
||||||
|
message={`¿Eliminar "${toDelete?.original_filename}"? Esta acción no se puede deshacer.`}
|
||||||
|
confirmLabel="Eliminar"
|
||||||
|
danger
|
||||||
|
loading={deleting}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onClose={() => setToDelete(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { Badge } from "../../components/ui/Misc";
|
||||||
|
import Button from "../../components/ui/Button";
|
||||||
|
import StorageBar from "../../components/StorageBar";
|
||||||
|
import {
|
||||||
|
QUESTION_TYPE_LABEL,
|
||||||
|
DIFFICULTY_LABEL,
|
||||||
|
} from "../../utils/constants";
|
||||||
|
import { formatDate } from "../../utils/format";
|
||||||
|
import Icon from "../../components/ui/Icon";
|
||||||
|
|
||||||
|
export default function OverviewTab({ template, storage, goToTab }) {
|
||||||
|
const qTypes = template.settings?.question_types || [];
|
||||||
|
const profile = template.difficulty_profile || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: "1.4fr 1fr" }}>
|
||||||
|
<div>
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-head">
|
||||||
|
<h3>Estructura del examen</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<h4 className="text-soft text-sm">Tipos de pregunta</h4>
|
||||||
|
{qTypes.map((qt, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex justify-between items-center"
|
||||||
|
style={{
|
||||||
|
padding: "10px 0",
|
||||||
|
borderBottom:
|
||||||
|
i < qTypes.length - 1 ? "1px solid var(--c-border)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>{QUESTION_TYPE_LABEL[qt.type] || qt.type}</strong>
|
||||||
|
{qt.type === "multichoice" && qt.options_count && (
|
||||||
|
<span className="text-faint text-sm">
|
||||||
|
{" "}· {qt.options_count} opciones
|
||||||
|
{qt.multiple_correct ? " · multi-respuesta" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-sm items-center">
|
||||||
|
<Badge variant="primary">{qt.count} preg.</Badge>
|
||||||
|
<span className="text-sm text-faint">
|
||||||
|
{qt.score} pt{qt.penalty ? ` · -${qt.penalty}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="divider-line" />
|
||||||
|
<h4 className="text-soft text-sm">Reparto por dificultad</h4>
|
||||||
|
<div className="flex gap-sm wrap">
|
||||||
|
{Object.entries(profile).map(([key, val]) =>
|
||||||
|
val > 0 ? (
|
||||||
|
<Badge key={key} variant={DIFFICULTY_LABEL[key]?.badge?.replace("badge-", "")}>
|
||||||
|
{DIFFICULTY_LABEL[key]?.label || key}: {val}
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider-line" />
|
||||||
|
<div className="flex gap-sm wrap text-sm">
|
||||||
|
<Badge variant={template.settings?.shuffle_questions ? "success" : undefined}>
|
||||||
|
<Icon
|
||||||
|
name={template.settings?.shuffle_questions ? "check" : "x"}
|
||||||
|
size={12}
|
||||||
|
className="icon-inline"
|
||||||
|
/>
|
||||||
|
Barajar preguntas
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={template.settings?.shuffle_answers ? "success" : undefined}>
|
||||||
|
<Icon
|
||||||
|
name={template.settings?.shuffle_answers ? "check" : "x"}
|
||||||
|
size={12}
|
||||||
|
className="icon-inline"
|
||||||
|
/>
|
||||||
|
Barajar respuestas
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={template.settings?.include_feedback ? "success" : undefined}>
|
||||||
|
<Icon
|
||||||
|
name={template.settings?.include_feedback ? "check" : "x"}
|
||||||
|
size={12}
|
||||||
|
className="icon-inline"
|
||||||
|
/>
|
||||||
|
Feedback
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-head">
|
||||||
|
<h3>Almacenamiento</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<StorageBar storage={storage} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card mb">
|
||||||
|
<div className="card-head">
|
||||||
|
<h3>Siguiente paso</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body flex" style={{ flexDirection: "column", gap: 10 }}>
|
||||||
|
<Button variant="subtle" block onClick={() => goToTab("materials")}>
|
||||||
|
<Icon name="book" size={16} className="icon-inline" />
|
||||||
|
Subir material para la IA
|
||||||
|
</Button>
|
||||||
|
<Button variant="subtle" block onClick={() => goToTab("images")}>
|
||||||
|
<Icon name="image" size={16} className="icon-inline" />
|
||||||
|
Añadir imágenes
|
||||||
|
</Button>
|
||||||
|
<Button block onClick={() => goToTab("generate")}>
|
||||||
|
<Icon name="sparkles" size={16} className="icon-inline" />
|
||||||
|
Generar preguntas
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-body text-sm text-faint">
|
||||||
|
Creado el {formatDate(template.created_at)}
|
||||||
|
<br />
|
||||||
|
Actualizado el {formatDate(template.updated_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { attachImageToQuestion } from "../../api/questions";
|
||||||
|
import { useToast } from "../../context/ToastContext";
|
||||||
|
import QuestionCard from "../../components/QuestionCard";
|
||||||
|
import AuthImage from "../../components/AuthImage";
|
||||||
|
import Button from "../../components/ui/Button";
|
||||||
|
import { EmptyState } from "../../components/ui/Misc";
|
||||||
|
import Modal from "../../components/ui/Modal";
|
||||||
|
import Icon from "../../components/ui/Icon";
|
||||||
|
|
||||||
|
export default function QuestionsTab({
|
||||||
|
questions,
|
||||||
|
images,
|
||||||
|
reloadQuestions,
|
||||||
|
goToTab,
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [editing, setEditing] = useState(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const setImage = async (questionId, imageId) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await attachImageToQuestion(questionId, imageId);
|
||||||
|
await reloadQuestions();
|
||||||
|
toast.success(imageId ? "Imagen vinculada." : "Imagen desvinculada.");
|
||||||
|
setEditing(null);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (questions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<EmptyState
|
||||||
|
icon="help"
|
||||||
|
title="Aún no hay preguntas"
|
||||||
|
message="Genera o importa preguntas desde la pestaña Generar."
|
||||||
|
action={
|
||||||
|
<Button onClick={() => goToTab("generate")}>
|
||||||
|
<Icon name="sparkles" size={16} className="icon-inline" />
|
||||||
|
Generar preguntas
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-center mb">
|
||||||
|
<p className="text-soft" style={{ margin: 0 }}>
|
||||||
|
{questions.length} preguntas guardadas. Vincula imágenes a las
|
||||||
|
preguntas que las necesiten.
|
||||||
|
</p>
|
||||||
|
<Button variant="subtle" size="sm" onClick={() => goToTab("export")}>
|
||||||
|
Exportar examen →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{questions.map((q, i) => (
|
||||||
|
<QuestionCard
|
||||||
|
key={q.id}
|
||||||
|
question={q}
|
||||||
|
index={i + 1}
|
||||||
|
footer={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setEditing(q)}
|
||||||
|
disabled={images.length === 0}
|
||||||
|
>
|
||||||
|
{q.image_id ? "Cambiar imagen" : "Vincular imagen"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{images.length === 0 && (
|
||||||
|
<p className="text-sm text-faint text-center">
|
||||||
|
Sube imágenes en la pestaña <strong>Imágenes</strong> para poder
|
||||||
|
vincularlas a las preguntas.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={!!editing}
|
||||||
|
onClose={() => !saving && setEditing(null)}
|
||||||
|
title="Vincular imagen a la pregunta"
|
||||||
|
large
|
||||||
|
>
|
||||||
|
<p className="text-soft text-sm">{editing?.statement}</p>
|
||||||
|
<div className="grid grid-cards mt">
|
||||||
|
<button
|
||||||
|
className="card card-pad"
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
textAlign: "center",
|
||||||
|
border: editing?.image_id
|
||||||
|
? "1px solid var(--c-border)"
|
||||||
|
: "2px solid var(--c-primary)",
|
||||||
|
background: "none",
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => setImage(editing.id, null)}
|
||||||
|
>
|
||||||
|
<div className="icon-wrap icon-box" style={{ margin: "0 auto 8px" }}>
|
||||||
|
<Icon name="ban" size={24} className="icon-muted" />
|
||||||
|
</div>
|
||||||
|
Sin imagen
|
||||||
|
</button>
|
||||||
|
{images.map((img) => {
|
||||||
|
const selected = editing?.image_id === img.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={img.id}
|
||||||
|
className="card card-pad"
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
border: selected
|
||||||
|
? "2px solid var(--c-primary)"
|
||||||
|
: "1px solid var(--c-border)",
|
||||||
|
background: "none",
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => setImage(editing.id, img.id)}
|
||||||
|
>
|
||||||
|
<AuthImage imageId={img.id} alt={img.original_filename} />
|
||||||
|
<div className="text-sm" style={{ marginTop: 8 }}>
|
||||||
|
{img.caption || img.original_filename}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export const QUESTION_TYPES = [
|
||||||
|
{ value: "multichoice", label: "Opción múltiple", icon: "listChecks" },
|
||||||
|
{ value: "truefalse", label: "Verdadero / Falso", icon: "toggle" },
|
||||||
|
{ value: "shortanswer", label: "Respuesta corta", icon: "pencil" },
|
||||||
|
{ value: "matching", label: "Emparejamiento", icon: "link" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const QUESTION_TYPE_LABEL = QUESTION_TYPES.reduce((acc, t) => {
|
||||||
|
acc[t.value] = t.label;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
export const DIFFICULTIES = [
|
||||||
|
{ value: "easy", label: "Fácil", badge: "badge-success" },
|
||||||
|
{ value: "medium", label: "Media", badge: "badge-info" },
|
||||||
|
{ value: "hard", label: "Difícil", badge: "badge-warn" },
|
||||||
|
{ value: "very_hard", label: "Muy difícil", badge: "badge-danger" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DIFFICULTY_LABEL = DIFFICULTIES.reduce((acc, d) => {
|
||||||
|
acc[d.value] = d;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
export const MATERIAL_ACCEPT = ".pdf,.docx,.txt,.md,.png,.jpg,.jpeg,.webp";
|
||||||
|
export const IMAGE_ACCEPT = ".png,.jpg,.jpeg,.webp,.gif";
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
export function formatBytes(bytes) {
|
||||||
|
if (bytes == null) return "—";
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
const kb = bytes / 1024;
|
||||||
|
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||||
|
return `${(kb / 1024).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("es-ES", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLastUpdated(iso) {
|
||||||
|
if (!iso) return "Última: —";
|
||||||
|
try {
|
||||||
|
const formatted = new Date(iso).toLocaleString("es-ES", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
return `Última: ${formatted.replace(/\./g, "")}`;
|
||||||
|
} catch {
|
||||||
|
return "Última: —";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateShort(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString("es-ES", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initials(text) {
|
||||||
|
if (!text) return "?";
|
||||||
|
const parts = text.trim().split(/[\s@.]+/).filter(Boolean);
|
||||||
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalQuestionsFromProfile(profile) {
|
||||||
|
if (!profile) return 0;
|
||||||
|
return (
|
||||||
|
(profile.easy || 0) +
|
||||||
|
(profile.medium || 0) +
|
||||||
|
(profile.hard || 0) +
|
||||||
|
(profile.very_hard || 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user