Compare commits
10 Commits
2c400543e9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 28ac986e00 | |||
| 085fee2b96 | |||
| 63c831f0dd | |||
| 37056a4066 | |||
| b88b3f334f | |||
| cccbe15275 | |||
| 31be326f2c | |||
| 180114ce38 | |||
| c8077e57ef | |||
| f4d46080a6 |
+7
-4
@@ -3,12 +3,15 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
# Variables de entorno (solo ignoramos las locales/sobre-escrituras)
|
# Variables de entorno (no subir secretos; sí .env.example)
|
||||||
# Mantenemos .env y .env.example versionados para compartir la configuración
|
.env
|
||||||
# de producción y el esqueleto de variables. Nunca subimos .env.local.
|
backend/.env
|
||||||
|
frontend/.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
.env
|
|
||||||
|
# GitLab CI (no se publica en GitHub)
|
||||||
|
.gitlab-ci.yml
|
||||||
|
|
||||||
# Override de Docker Compose para desarrollo local (no debe llegar a producción)
|
# Override de Docker Compose para desarrollo local (no debe llegar a producción)
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
+11
-5
@@ -16,10 +16,10 @@
|
|||||||
GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com
|
GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com
|
||||||
GOOGLE_CLIENT_SECRET=tu-client-secret
|
GOOGLE_CLIENT_SECRET=tu-client-secret
|
||||||
# URI a la que Google redirige tras el login (debe estar registrada en Google Cloud)
|
# URI a la que Google redirige tras el login (debe estar registrada en Google Cloud)
|
||||||
# Con Docker: suele ser el frontend (8071) porque /api hace proxy al backend
|
# Con Docker local: el frontend (8071) hace proxy de /api al backend
|
||||||
GOOGLE_REDIRECT_URI=http://localhost:8071/api/auth/google/callback
|
GOOGLE_REDIRECT_URI=http://localhost:8071/api/auth/google/callback
|
||||||
# Producción (añade la misma URI en Google Console → Credenciales OAuth):
|
# Producción Sinbad2 (HTTPS vía Apache, prefijo /deckofcards):
|
||||||
# GOOGLE_REDIRECT_URI=http://tu-servidor:8071/api/auth/google/callback
|
# GOOGLE_REDIRECT_URI=https://sinbad2.ujaen.es/deckofcards/api/auth/google/callback
|
||||||
|
|
||||||
# Clave para firmar los JWT (usa algo largo y aleatorio en producción)
|
# Clave para firmar los JWT (usa algo largo y aleatorio en producción)
|
||||||
SECRET_KEY=cambia-esta-clave-en-produccion
|
SECRET_KEY=cambia-esta-clave-en-produccion
|
||||||
@@ -27,8 +27,14 @@ SECRET_KEY=cambia-esta-clave-en-produccion
|
|||||||
# URL del frontend a la que se redirige tras el login con Google
|
# URL del frontend a la que se redirige tras el login con Google
|
||||||
# Con docker-compose en local: http://localhost:8071
|
# Con docker-compose en local: http://localhost:8071
|
||||||
# Con Vite directo en local: http://localhost:5173
|
# Con Vite directo en local: http://localhost:5173
|
||||||
# En producción: https://tu-dominio.com
|
# Producción Sinbad2: https://sinbad2.ujaen.es/deckofcards
|
||||||
FRONTEND_URL=http://localhost:5173
|
FRONTEND_URL=http://localhost:8071
|
||||||
|
|
||||||
|
# Entorno y seguridad HTTPS (producción en Sinbad2)
|
||||||
|
# ENVIRONMENT=production
|
||||||
|
# CORS_ALLOWED_ORIGINS=https://sinbad2.ujaen.es
|
||||||
|
# TRUSTED_HOSTS=sinbad2.ujaen.es,backend,localhost
|
||||||
|
# SECURITY_HSTS_SECONDS=31536000
|
||||||
|
|
||||||
# Verificación de email por código numérico
|
# Verificación de email por código numérico
|
||||||
# El destinatario puede ser cualquier proveedor (Gmail, Hotmail, Outlook, etc.).
|
# El destinatario puede ser cualquier proveedor (Gmail, Hotmail, Outlook, etc.).
|
||||||
|
|||||||
+12
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM python:3.10-slim
|
FROM python:3.10-slim AS base
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -8,4 +8,15 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
FROM base AS development
|
||||||
|
|
||||||
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|
||||||
|
FROM base AS production
|
||||||
|
|
||||||
|
CMD ["uvicorn", "api.main:app", \
|
||||||
|
"--host", "0.0.0.0", \
|
||||||
|
"--port", "8000", \
|
||||||
|
"--proxy-headers", \
|
||||||
|
"--forwarded-allow-ips", "*", \
|
||||||
|
"--no-server-header"]
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
def _split_csv(value: str | None) -> list[str]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
|
||||||
|
SECURITY_HSTS_SECONDS: int = int(os.getenv("SECURITY_HSTS_SECONDS", "31536000"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_production(self) -> bool:
|
||||||
|
return self.ENVIRONMENT.strip().lower() == "production"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_allowed_origins(self) -> list[str]:
|
||||||
|
configured = _split_csv(os.getenv("CORS_ALLOWED_ORIGINS"))
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
if self.is_production:
|
||||||
|
frontend = os.getenv("FRONTEND_URL", "").rstrip("/")
|
||||||
|
return [frontend] if frontend else []
|
||||||
|
return ["*"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def trusted_hosts(self) -> list[str]:
|
||||||
|
configured = _split_csv(os.getenv("TRUSTED_HOSTS"))
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
if self.is_production:
|
||||||
|
return ["sinbad2.ujaen.es"]
|
||||||
|
return ["*"]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
+27
-6
@@ -1,7 +1,12 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from contextlib import asynccontextmanager
|
from starlette.middleware.trustedhost import TrustedHostMiddleware
|
||||||
|
|
||||||
|
from api.config.settings import get_settings
|
||||||
from api.database.mongodb import db
|
from api.database.mongodb import db
|
||||||
|
from api.middleware.security_headers import SecurityHeadersMiddleware
|
||||||
|
|
||||||
# Routers
|
# Routers
|
||||||
from api.routers.test_mongo import router as test_mongo_router
|
from api.routers.test_mongo import router as test_mongo_router
|
||||||
@@ -20,16 +25,32 @@ from api.routers.google_auth import router as google_auth_router
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
app = FastAPI(lifespan=lifespan)
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(SecurityHeadersMiddleware, settings=settings)
|
||||||
|
|
||||||
|
if settings.is_production:
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
TrustedHostMiddleware,
|
||||||
allow_origins=["*"],
|
allowed_hosts=settings.trusted_hosts,
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cors_origins = settings.cors_allowed_origins
|
||||||
|
cors_kwargs = {
|
||||||
|
"allow_methods": ["*"],
|
||||||
|
"allow_headers": ["*"],
|
||||||
|
}
|
||||||
|
if cors_origins == ["*"]:
|
||||||
|
cors_kwargs["allow_origins"] = ["*"]
|
||||||
|
cors_kwargs["allow_credentials"] = False
|
||||||
|
else:
|
||||||
|
cors_kwargs["allow_origins"] = cors_origins
|
||||||
|
cors_kwargs["allow_credentials"] = True
|
||||||
|
|
||||||
|
app.add_middleware(CORSMiddleware, **cors_kwargs)
|
||||||
|
|
||||||
app.include_router(test_mongo_router, prefix="/api")
|
app.include_router(test_mongo_router, prefix="/api")
|
||||||
app.include_router(value_router, prefix="/api/criteria/doc")
|
app.include_router(value_router, prefix="/api/criteria/doc")
|
||||||
app.include_router(docmf_build_router, prefix="/api/criteria/doc-mf")
|
app.include_router(docmf_build_router, prefix="/api/criteria/doc-mf")
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from api.config.settings import Settings, get_settings
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
|
def __init__(self, app, settings: Settings | None = None):
|
||||||
|
super().__init__(app)
|
||||||
|
self._settings = settings or get_settings()
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||||
|
|
||||||
|
if request.url.scheme == "https" or self._settings.is_production:
|
||||||
|
hsts = f"max-age={self._settings.SECURITY_HSTS_SECONDS}"
|
||||||
|
response.headers.setdefault("Strict-Transport-Security", hsts)
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
target: production
|
||||||
|
container_name: backend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8070:8000"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
env_file:
|
||||||
|
- backend/.env
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
target: production
|
||||||
|
args:
|
||||||
|
VITE_BASE_PATH: /deckofcards/
|
||||||
|
VITE_API_URL: /deckofcards/api
|
||||||
|
container_name: frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8071:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mongo:4.4
|
||||||
|
container_name: mongo
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "27018:27017"
|
||||||
|
volumes:
|
||||||
|
- mongo_data:/data/db
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mongo_data:
|
||||||
@@ -2,6 +2,7 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
|
target: development
|
||||||
container_name: backend
|
container_name: backend
|
||||||
ports:
|
ports:
|
||||||
- "8070:8000"
|
- "8070:8000"
|
||||||
@@ -15,6 +16,7 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
|
target: development
|
||||||
container_name: frontend
|
container_name: frontend
|
||||||
ports:
|
ports:
|
||||||
- "8071:5173"
|
- "8071:5173"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
# - Backend ejecutado fuera de Docker (uvicorn --port 8000):
|
# - Backend ejecutado fuera de Docker (uvicorn --port 8000):
|
||||||
# VITE_API_URL=http://localhost:8000/api
|
# VITE_API_URL=http://localhost:8000/api
|
||||||
#
|
#
|
||||||
# - Producción (ya definido en .env):
|
# - Producción Sinbad2 (HTTPS, prefijo /deckofcards; build en docker-compose.prod.yaml):
|
||||||
# VITE_API_URL=http://sinbad2.ujaen.es:8070/api
|
# VITE_BASE_PATH=/deckofcards/
|
||||||
|
# VITE_API_URL=/deckofcards/api
|
||||||
VITE_API_URL=http://localhost:8070/api
|
VITE_API_URL=http://localhost:8070/api
|
||||||
|
|||||||
+2
-2
@@ -12,8 +12,8 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Variables de entorno locales de Vite
|
# Variables de entorno locales de Vite (no subir secretos; sí .env.example)
|
||||||
# (.env y .env.example sí se versionan)
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
|||||||
+32
-1
@@ -1,5 +1,36 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine AS development
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host"]
|
CMD ["npm", "run", "dev", "--", "--host"]
|
||||||
|
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG VITE_BASE_PATH=/deckofcards/
|
||||||
|
ARG VITE_API_URL=/deckofcards/api
|
||||||
|
|
||||||
|
ENV VITE_BASE_PATH=$VITE_BASE_PATH
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS production
|
||||||
|
|
||||||
|
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;"]
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Deck of Cards</title>
|
<title>Deck of Cards</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Apache en Sinbad2 termina TLS en https://sinbad2.ujaen.es/deckofcards
|
||||||
|
# y reenvía el tráfico a este contenedor (puerto 8071) sin el prefijo público:
|
||||||
|
# ProxyPass /deckofcards http://host.docker.internal:8071/
|
||||||
|
# El navegador sigue viendo /deckofcards/...; aquí llegan rutas como /, /api/, /assets/.
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
|
proxy_set_header X-Forwarded-Host $http_x_forwarded_host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
ComposedChart, Area, Line,
|
||||||
|
XAxis, YAxis, CartesianGrid,
|
||||||
|
ReferenceArea, ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
// ── Fake demo data ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const STEP1_CARDS = ['Bajo', 'Medio', 'Alto', 'Perfecto'];
|
||||||
|
const STEP1_BLANKS = [3, 1, 4]; // huecos asimétricos entre cartas
|
||||||
|
|
||||||
|
const STEP2_TERMS = [
|
||||||
|
{ name: 'Bajo', xVal: 0.08, mf: { supportStart: 0.00, coreStart: 0.00, coreEnd: 0.06, supportEnd: 0.30 } },
|
||||||
|
{ name: 'Medio', xVal: 0.38, mf: { supportStart: 0.18, coreStart: 0.38, coreEnd: 0.38, supportEnd: 0.59 } }, // pico/triángulo
|
||||||
|
{ name: 'Alto', xVal: 0.65, mf: { supportStart: 0.52, coreStart: 0.60, coreEnd: 0.69, supportEnd: 0.77 } },
|
||||||
|
{ name: 'Perfecto', xVal: 0.92, mf: { supportStart: 0.74, coreStart: 0.88, coreEnd: 1.00, supportEnd: 1.00 } },
|
||||||
|
];
|
||||||
|
const STEP2_COLORS = ['#ef4444', '#f59e0b', '#10b981', '#3b82f6'];
|
||||||
|
|
||||||
|
// Igual que interpolateY en useGraphData.js:
|
||||||
|
// · Zona buffer justo fuera del soporte → 0 (ancla la línea en y=0)
|
||||||
|
// · Más allá del buffer → null (corte, sin línea horizontal)
|
||||||
|
const TRAP_BUF = 1.1e-4;
|
||||||
|
function trapVal(x, s0, c0, c1, s1) {
|
||||||
|
if (x < s0 - TRAP_BUF || x > s1 + TRAP_BUF) return null;
|
||||||
|
if (x < s0 || x > s1) return 0;
|
||||||
|
if (x >= c0 && x <= c1) return 1;
|
||||||
|
if (x < c0 && c0 > s0) return (x - s0) / (c0 - s0);
|
||||||
|
if (x > c1 && s1 > c1) return (s1 - x) / (s1 - c1);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solo 'Alto' es IT2 (tiene subescala). UMF más ancha; LMF con el mismo núcleo trapecial que el paso 2.
|
||||||
|
const STEP3_TERMS = [
|
||||||
|
{ name: 'Bajo', color: '#ef4444', type: 't1', pts: [0.00, 0.00, 0.06, 0.30] },
|
||||||
|
{ name: 'Medio', color: '#f59e0b', type: 't1', pts: [0.18, 0.38, 0.38, 0.59] },
|
||||||
|
{ name: 'Alto', color: '#10b981', type: 't2', u: [0.49, 0.57, 0.71, 0.81], l: [0.56, 0.60, 0.69, 0.76] },
|
||||||
|
{ name: 'Perfecto', color: '#3b82f6', type: 't1', pts: [0.74, 0.88, 1.00, 1.00] },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Puntos clave del trapecio (piezas lineales → basta con vértices, como en el paso 2).
|
||||||
|
// Recharts interpola el trazo entre ellos con animación fluida.
|
||||||
|
function getTermLineData(term) {
|
||||||
|
const name = term.name;
|
||||||
|
|
||||||
|
if (term.type === 't1') {
|
||||||
|
const [s0, c0, c1, s1] = term.pts;
|
||||||
|
const xs = new Set([s0, c0, c1, s1]);
|
||||||
|
if (s0 <= 0.001) xs.add(s0 - TRAP_BUF);
|
||||||
|
if (s1 >= 0.999) xs.add(s1 + TRAP_BUF);
|
||||||
|
return Array.from(xs).sort((a, b) => a - b).map(x => ({
|
||||||
|
x,
|
||||||
|
[name]: trapVal(x, s0, c0, c1, s1),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const xs = new Set([...term.u, ...term.l]);
|
||||||
|
return Array.from(xs).sort((a, b) => a - b).map(x => {
|
||||||
|
const upper = trapVal(x, ...term.u);
|
||||||
|
const lower = trapVal(x, ...term.l);
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
[`${name}_upper`]: upper,
|
||||||
|
[`${name}_lower`]: lower,
|
||||||
|
[`${name}_range`]: (lower === null && upper === null)
|
||||||
|
? null
|
||||||
|
: [lower ?? 0, upper ?? 0],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEP_LABELS = [
|
||||||
|
{ n: 1, label: 'Escala' },
|
||||||
|
{ n: 2, label: 'Modelado' },
|
||||||
|
{ n: 3, label: 'Espectro IT2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Step sub-components ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Step1Content({ count }) {
|
||||||
|
const done = count >= STEP1_CARDS.length;
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col items-center justify-center gap-4 py-2">
|
||||||
|
<p className="text-[11px] font-bold text-blue-500 self-start">Criterio: Calidad Investigadora</p>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
{STEP1_CARDS.map((name, i) => (
|
||||||
|
<React.Fragment key={name}>
|
||||||
|
{i > 0 && (
|
||||||
|
<div className="flex flex-col items-center mx-1 mb-9">
|
||||||
|
<div className={`h-px w-10 transition-all duration-500 ${i < count ? 'bg-slate-300' : 'bg-slate-100'}`} />
|
||||||
|
<span className={`text-[10px] font-bold mt-1 transition-all duration-500 ${i < count ? 'text-slate-400' : 'text-slate-100'}`}>
|
||||||
|
×{STEP1_BLANKS[i - 1]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: i < count ? 1 : 0,
|
||||||
|
transform: i < count ? 'translateY(0) scale(1)' : 'translateY(8px) scale(0.85)',
|
||||||
|
transition: 'opacity 0.4s ease, transform 0.4s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`w-20 h-28 bg-white border-2 rounded-2xl shadow-sm flex flex-col items-center justify-center relative transition-all duration-300 ${i === count - 1 ? 'border-blue-300 shadow-blue-100 shadow-md' : 'border-slate-200'}`}>
|
||||||
|
<span className="absolute top-1.5 left-2.5 text-[10px] font-black text-slate-200">{i + 1}</span>
|
||||||
|
<span className="absolute bottom-1.5 right-2.5 text-[10px] font-black text-slate-200 rotate-180">{i + 1}</span>
|
||||||
|
<span className="text-xs font-bold text-slate-700 text-center px-1.5 leading-tight">{name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={`flex items-center justify-center gap-2.5 transition-all duration-500 ${done ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
|
<span className="text-xs font-bold text-emerald-600">✓ {STEP1_CARDS.length} niveles definidos</span>
|
||||||
|
<div className="h-1.5 w-24 rounded-full bg-emerald-100">
|
||||||
|
<div className="h-1.5 rounded-full bg-emerald-500 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step2Content({ count }) {
|
||||||
|
const visibleTerms = STEP2_TERMS.slice(0, count);
|
||||||
|
const activeIndex = count - 1;
|
||||||
|
const showSubscale = count >= 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col justify-center gap-2 py-1">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{STEP2_TERMS.map((term, i) => {
|
||||||
|
const color = STEP2_COLORS[i % STEP2_COLORS.length];
|
||||||
|
const isVisible = i < count;
|
||||||
|
const isActive = i === activeIndex;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={term.name}
|
||||||
|
className="px-2.5 py-0.5 rounded-lg text-[10px] font-bold border-2 transition-all duration-500"
|
||||||
|
style={
|
||||||
|
isActive
|
||||||
|
? { backgroundColor: color, borderColor: color, color: '#fff', transform: 'scale(1.1)' }
|
||||||
|
: isVisible
|
||||||
|
? { borderColor: color, color: '#64748b', backgroundColor: 'white' }
|
||||||
|
: { borderColor: '#e2e8f0', color: '#cbd5e1', backgroundColor: 'white' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{term.name}
|
||||||
|
{i === 2 && showSubscale && (
|
||||||
|
<span className="ml-1 text-[8px] font-black bg-purple-100 text-purple-600 rounded px-0.5">IT2</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full rounded-2xl border border-slate-200 p-2" style={{ backgroundColor: '#f8fafc' }}>
|
||||||
|
<ResponsiveContainer width="99%" height={148}>
|
||||||
|
<ComposedChart margin={{ top: 12, right: 16, left: 0, bottom: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||||
|
<XAxis type="number" dataKey="x" domain={[0, 1]} ticks={[0, 0.25, 0.5, 0.75, 1]} tick={{ fill: '#94a3b8', fontSize: 10, fontWeight: 600 }} />
|
||||||
|
<YAxis domain={[0, 1]} ticks={[0, 0.5, 1]} tick={{ fill: '#94a3b8', fontSize: 10 }} width={24} />
|
||||||
|
{visibleTerms.map((term, i) => {
|
||||||
|
const color = STEP2_COLORS[i % STEP2_COLORS.length];
|
||||||
|
const isActive = i === activeIndex;
|
||||||
|
return (
|
||||||
|
<ReferenceLine
|
||||||
|
key={`ref-${term.name}`}
|
||||||
|
x={term.xVal}
|
||||||
|
stroke={color}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
strokeWidth={isActive ? 2 : 1}
|
||||||
|
label={{ position: 'top', value: term.name, fill: color, fontWeight: isActive ? '900' : '600', fontSize: 10 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{visibleTerms.map((term, i) => {
|
||||||
|
const color = STEP2_COLORS[i % STEP2_COLORS.length];
|
||||||
|
const isActive = i === activeIndex;
|
||||||
|
return (
|
||||||
|
<ReferenceArea
|
||||||
|
key={`area-${term.name}`}
|
||||||
|
x1={term.mf.supportStart}
|
||||||
|
x2={term.mf.supportEnd}
|
||||||
|
fill={color}
|
||||||
|
fillOpacity={isActive ? 0.22 : 0.07}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{visibleTerms.map((term, i) => {
|
||||||
|
const color = STEP2_COLORS[i % STEP2_COLORS.length];
|
||||||
|
const isActive = i === activeIndex;
|
||||||
|
const trapezeData = [
|
||||||
|
{ x: term.mf.supportStart, y: 0 },
|
||||||
|
{ x: term.mf.coreStart, y: 1 },
|
||||||
|
{ x: term.mf.coreEnd, y: 1 },
|
||||||
|
{ x: term.mf.supportEnd, y: 0 },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={`line-${term.name}`}
|
||||||
|
data={trapezeData}
|
||||||
|
dataKey="y"
|
||||||
|
type="linear"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={isActive ? 3 : 2}
|
||||||
|
dot={isActive ? { r: 4, fill: color, stroke: '#fff', strokeWidth: 2 } : false}
|
||||||
|
activeDot={false}
|
||||||
|
isAnimationActive={isActive}
|
||||||
|
animationDuration={500}
|
||||||
|
animationEasing="ease-out"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini SubscaleModal inline */}
|
||||||
|
<div className={`w-full rounded-xl border border-purple-200 bg-purple-50/60 overflow-hidden transition-all duration-500 ${showSubscale ? 'opacity-100 max-h-32' : 'opacity-0 max-h-0 border-transparent pointer-events-none'}`}>
|
||||||
|
<div className="px-3 py-1.5 border-b border-purple-100 flex items-center gap-1.5">
|
||||||
|
<span className="text-[9px] font-black text-purple-700 uppercase tracking-wider">Diseñar Subescala</span>
|
||||||
|
<span className="text-[9px] text-purple-300">·</span>
|
||||||
|
<span className="text-[9px] font-bold text-emerald-600">Alto</span>
|
||||||
|
<span className="text-[9px] text-purple-300">·</span>
|
||||||
|
<span className="text-[9px] text-slate-400">Pendiente Descendente</span>
|
||||||
|
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-purple-400 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="px-3 py-2.5 flex items-center justify-center gap-2">
|
||||||
|
<div className="w-9 h-12 bg-white border-2 border-slate-200 rounded-lg flex items-center justify-center shrink-0 shadow-sm">
|
||||||
|
<span className="text-xs font-black text-slate-300">1</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
|
<div className="flex items-end gap-1">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-[7px] font-bold text-slate-400 leading-none">MÍN</span>
|
||||||
|
<span className="text-[10px] font-black text-slate-700 bg-white border border-slate-200 rounded px-1.5 py-0.5 shadow-sm">2</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-slate-300 mb-0.5">—</span>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-[7px] font-bold text-slate-400 leading-none">MÁX</span>
|
||||||
|
<span className="text-[10px] font-black text-slate-700 bg-white border border-slate-200 rounded px-1.5 py-0.5 shadow-sm">5</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[8px] font-bold text-blue-500 whitespace-nowrap">¿Dudas? Rango ✓</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-9 h-12 bg-white border-2 border-slate-200 rounded-lg flex items-center justify-center shrink-0 shadow-sm">
|
||||||
|
<span className="text-xs font-black text-slate-300">2</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-[7px] font-bold text-slate-400 leading-none">CARTAS</span>
|
||||||
|
<span className="text-[10px] font-black text-slate-700 bg-white border border-slate-200 rounded px-1.5 py-0.5 shadow-sm">3</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[8px] font-semibold text-slate-400 whitespace-nowrap">Distancia exacta</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-9 h-12 bg-white border-2 border-slate-200 rounded-lg flex items-center justify-center shrink-0 shadow-sm">
|
||||||
|
<span className="text-xs font-black text-slate-300">3</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step3Content({ count }) {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col items-center justify-center gap-2 py-1">
|
||||||
|
<p className="text-[11px] font-bold text-blue-500 self-start">Espectro difuso · Calidad Investigadora</p>
|
||||||
|
<div className="w-full rounded-2xl border border-slate-200 px-2 pt-2 pb-1.5" style={{ backgroundColor: '#f8fafc' }}>
|
||||||
|
<ResponsiveContainer width="99%" height={168}>
|
||||||
|
<ComposedChart margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||||
|
<XAxis type="number" dataKey="x" domain={[0, 1]} allowDataOverflow={true} ticks={[0, 0.25, 0.5, 0.75, 1]} tick={{ fill: '#94a3b8', fontSize: 10, fontWeight: 600 }} />
|
||||||
|
<YAxis domain={[0, 1]} ticks={[0, 0.5, 1]} tick={{ fill: '#94a3b8', fontSize: 10 }} width={24} />
|
||||||
|
{STEP3_TERMS.slice(0, count).map((term, i) => {
|
||||||
|
const isNewest = i === count - 1;
|
||||||
|
const data = getTermLineData(term);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={term.name}>
|
||||||
|
{term.type === 't1' ? (
|
||||||
|
<Line
|
||||||
|
data={data}
|
||||||
|
type="linear"
|
||||||
|
dataKey={term.name}
|
||||||
|
stroke={term.color}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
dot={false}
|
||||||
|
connectNulls={false}
|
||||||
|
isAnimationActive={isNewest}
|
||||||
|
animationDuration={900}
|
||||||
|
animationEasing="ease-out"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Area data={data} type="linear" dataKey={`${term.name}_range`} fill={term.color} fillOpacity={0.35} stroke="none" connectNulls={false} isAnimationActive={isNewest} animationDuration={900} animationEasing="ease-out" />
|
||||||
|
<Line data={data} type="linear" dataKey={`${term.name}_upper`} stroke={term.color} strokeWidth={1.5} strokeDasharray="5 4" dot={false} connectNulls={false} isAnimationActive={isNewest} animationDuration={900} animationEasing="ease-out" />
|
||||||
|
<Line data={data} type="linear" dataKey={`${term.name}_lower`} stroke={term.color} strokeWidth={2.5} dot={false} connectNulls={false} isAnimationActive={isNewest} animationDuration={900} animationEasing="ease-out" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-3 gap-y-0.5 px-1 pt-0.5 pb-0.5">
|
||||||
|
{STEP3_TERMS.map((term, i) => (
|
||||||
|
<div
|
||||||
|
key={term.name}
|
||||||
|
className="flex items-center gap-1.5 transition-all duration-500"
|
||||||
|
style={{ opacity: i < count ? 1 : 0, transform: i < count ? 'translateY(0)' : 'translateY(4px)' }}
|
||||||
|
>
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: term.color }} />
|
||||||
|
<span className="text-[10px] font-bold" style={{ color: term.color }}>{term.name}</span>
|
||||||
|
{term.type === 't2' && i < count && (
|
||||||
|
<span className="text-[8px] font-black bg-purple-100 text-purple-600 rounded px-0.5">IT2</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function AuthDemoPanel() {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [fading, setFading] = useState(false);
|
||||||
|
const innerTimerRef = useRef(null);
|
||||||
|
|
||||||
|
const transitionTo = useCallback((nextStep) => {
|
||||||
|
if (innerTimerRef.current) clearTimeout(innerTimerRef.current);
|
||||||
|
setFading(true);
|
||||||
|
innerTimerRef.current = setTimeout(() => {
|
||||||
|
setStep(nextStep);
|
||||||
|
setCount(0);
|
||||||
|
setFading(false);
|
||||||
|
}, 440);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timeout;
|
||||||
|
if (step === 1) {
|
||||||
|
if (count < STEP1_CARDS.length) {
|
||||||
|
timeout = setTimeout(() => setCount(c => c + 1), 580);
|
||||||
|
} else {
|
||||||
|
timeout = setTimeout(() => transitionTo(2), 950);
|
||||||
|
}
|
||||||
|
} else if (step === 2) {
|
||||||
|
if (count < STEP2_TERMS.length) {
|
||||||
|
timeout = setTimeout(() => setCount(c => c + 1), 920);
|
||||||
|
} else {
|
||||||
|
timeout = setTimeout(() => transitionTo(3), 1300);
|
||||||
|
}
|
||||||
|
} else if (step === 3) {
|
||||||
|
if (count < STEP3_TERMS.length) {
|
||||||
|
timeout = setTimeout(() => setCount(c => c + 1), 920);
|
||||||
|
} else {
|
||||||
|
timeout = setTimeout(() => transitionTo(1), 2800);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [step, count, transitionTo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { if (innerTimerRef.current) clearTimeout(innerTimerRef.current); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mt-5 flex-1 w-full flex flex-col gap-3 min-h-0 transition-opacity duration-500"
|
||||||
|
style={{ opacity: fading ? 0 : 1 }}
|
||||||
|
>
|
||||||
|
{/* Step breadcrumb */}
|
||||||
|
<div className="shrink-0 flex items-center gap-1">
|
||||||
|
{STEP_LABELS.map((s, i) => (
|
||||||
|
<React.Fragment key={s.n}>
|
||||||
|
<div className={`flex items-center gap-1.5 px-2 py-1 rounded-lg transition-all duration-300 ${step === s.n ? 'bg-blue-100' : ''}`}>
|
||||||
|
<span className={`w-4 h-4 rounded-full text-[9px] flex items-center justify-center font-black transition-all duration-300 ${step === s.n ? 'bg-blue-600 text-white' : step > s.n ? 'bg-emerald-500 text-white' : 'bg-slate-200 text-slate-400'}`}>
|
||||||
|
{step > s.n ? '✓' : s.n}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[10px] font-bold transition-all duration-300 ${step === s.n ? 'text-blue-700' : step > s.n ? 'text-emerald-600' : 'text-slate-400'}`}>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{i < STEP_LABELS.length - 1 && (
|
||||||
|
<span className="text-slate-300 text-xs mx-0.5">›</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
<span className="ml-auto flex items-center gap-1.5">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
|
<span className="text-[10px] text-emerald-500 font-bold">En vivo</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex flex-col justify-center min-h-0">
|
||||||
|
{step === 1 && <Step1Content count={count} />}
|
||||||
|
{step === 2 && <Step2Content count={count} />}
|
||||||
|
{step === 3 && <Step3Content count={count} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
export default function CriterionInput({ criterionName, setCriterionName, error }) {
|
export default function CriterionInput({ criterionName, setCriterionName, error }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row items-center justify-center gap-3 w-full z-30 relative mt-4">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-center gap-2 sm:gap-3 w-full max-w-full z-30 relative mt-4 px-1">
|
||||||
<label className="text-sm font-bold text-slate-600 uppercase tracking-wide whitespace-nowrap">
|
<label className="text-sm font-bold text-slate-600 uppercase tracking-wide text-center sm:text-left sm:whitespace-nowrap shrink-0">
|
||||||
Nombre del Criterio:
|
Nombre del Criterio:
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="relative w-72">
|
<div className="relative w-full max-w-xs sm:max-w-none sm:w-72 mx-auto sm:mx-0">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ej: Calidad del código"
|
placeholder="Ej: Calidad del código"
|
||||||
@@ -19,7 +19,7 @@ export default function CriterionInput({ criterionName, setCriterionName, error
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<span className="absolute top-1/2 -right-18 -translate-y-1/2 text-red-500 text-xs font-semibold">
|
<span className="mt-1 block text-center sm:absolute sm:mt-0 sm:top-1/2 sm:-right-20 sm:-translate-y-1/2 text-red-500 text-xs font-semibold whitespace-nowrap">
|
||||||
Obligatorio
|
Obligatorio
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,14 +14,15 @@ export default function Step1BaseScale({
|
|||||||
const [isZoomActive, setIsZoomActive] = useState(true);
|
const [isZoomActive, setIsZoomActive] = useState(true);
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const tableRef = useRef(null);
|
const tableRef = useRef(null);
|
||||||
const [dimensions, setDimensions] = useState({ container: 1000, table: 0 });
|
const [dimensions, setDimensions] = useState({ container: 1000, table: 0, tableHeight: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateMeasurements = () => {
|
const updateMeasurements = () => {
|
||||||
if (containerRef.current && tableRef.current) {
|
if (containerRef.current && tableRef.current) {
|
||||||
setDimensions({
|
setDimensions({
|
||||||
container: containerRef.current.offsetWidth,
|
container: containerRef.current.offsetWidth,
|
||||||
table: tableRef.current.scrollWidth
|
table: tableRef.current.scrollWidth,
|
||||||
|
tableHeight: tableRef.current.offsetHeight,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -37,8 +38,13 @@ export default function Step1BaseScale({
|
|||||||
const dynamicScale = needsZoom ? (dimensions.container / dimensions.table) * 0.95 : 1;
|
const dynamicScale = needsZoom ? (dimensions.container / dimensions.table) * 0.95 : 1;
|
||||||
const currentScale = isZoomActive && needsZoom ? dynamicScale : 1;
|
const currentScale = isZoomActive && needsZoom ? dynamicScale : 1;
|
||||||
|
|
||||||
|
const isScaledLayout = isZoomActive && needsZoom && currentScale < 1 && dimensions.tableHeight > 0;
|
||||||
|
const scaledViewportHeight = isScaledLayout
|
||||||
|
? dimensions.tableHeight * currentScale + 12
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 flex flex-col items-center animate-fade-in relative overflow-visible">
|
<div className="w-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 flex flex-col items-center animate-fade-in relative overflow-x-clip">
|
||||||
|
|
||||||
<div className="flex justify-between items-center w-full mb-4 border-b pb-3 relative z-30">
|
<div className="flex justify-between items-center w-full mb-4 border-b pb-3 relative z-30">
|
||||||
<h2 className="text-xl font-bold text-slate-800">
|
<h2 className="text-xl font-bold text-slate-800">
|
||||||
@@ -60,10 +66,16 @@ export default function Step1BaseScale({
|
|||||||
|
|
||||||
<CriterionInput criterionName={criterionName} setCriterionName={handleCriterionChange} error={errors.criterion} />
|
<CriterionInput criterionName={criterionName} setCriterionName={handleCriterionChange} error={errors.criterion} />
|
||||||
|
|
||||||
<div ref={containerRef} className={`w-full mt-2 transition-all relative ${!isZoomActive && needsZoom ? 'overflow-x-auto flex justify-start pt-4 px-4 custom-scrollbar' : 'overflow-visible flex justify-center pt-4'}`}>
|
<div ref={containerRef} className={`w-full mt-2 transition-all relative ${!isZoomActive && needsZoom ? 'overflow-x-auto flex justify-start pt-4 px-4 pb-4 custom-scrollbar' : 'overflow-x-clip flex justify-center pt-4'}`}>
|
||||||
<div className={`flex flex-row items-start min-w-max transition-transform duration-500 ease-out px-4 origin-top`} style={{ transform: `scale(${currentScale})`, marginBottom: isZoomActive && currentScale < 1 ? `-${(1 - currentScale) * 300}px` : '0px' }}>
|
<div
|
||||||
|
className="flex w-full justify-center transition-[height] duration-500 ease-out"
|
||||||
<div ref={tableRef} className="flex flex-row items-start relative px-10 overflow-visible">
|
style={isScaledLayout ? { height: scaledViewportHeight } : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex flex-row items-start min-w-max px-4 origin-top transition-transform duration-500 ease-out"
|
||||||
|
style={{ transform: `scale(${currentScale})` }}
|
||||||
|
>
|
||||||
|
<div ref={tableRef} className="flex flex-row items-start relative px-10 overflow-x-clip">
|
||||||
{levels.map((level, index) => (
|
{levels.map((level, index) => (
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
|
|
||||||
@@ -96,7 +108,7 @@ export default function Step1BaseScale({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,27 +6,27 @@ export default function Footer() {
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-8 lg:gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-8 lg:gap-6">
|
||||||
|
|
||||||
{/* Proyecto */}
|
{/* Proyecto */}
|
||||||
<div className="lg:col-span-4 flex flex-col">
|
<div className="lg:col-span-4 flex flex-col items-center text-center sm:items-start sm:text-left">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex flex-wrap items-center justify-center gap-3 mb-3 sm:justify-start">
|
||||||
<span className="text-xl font-black text-slate-800 tracking-tight">Deck of Cards</span>
|
<span className="text-xl font-black text-slate-800 tracking-tight">Deck of Cards</span>
|
||||||
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-[10px] font-black uppercase tracking-widest rounded-md">
|
<span className="px-2 py-1 bg-blue-50 text-blue-700 text-[10px] font-black uppercase tracking-widest rounded-md">
|
||||||
Software Científico
|
Software Científico
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-500 leading-relaxed max-w-sm">
|
<p className="text-sm text-slate-500 leading-relaxed max-w-sm">
|
||||||
Plataforma web para la elicitación de escalas de valor y construcción de conjuntos difusos interpretables (DoC-MF).
|
Elicitación de escalas de valor y construcción de conjuntos difusos interpretables (DoC-MF).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desarrollo */}
|
{/* Desarrollo */}
|
||||||
<div className="lg:col-span-3 flex flex-col">
|
<div className="lg:col-span-3 flex flex-col items-center text-center sm:items-start sm:text-left">
|
||||||
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-3">Ingeniería y Desarrollo</h4>
|
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-3">Ingeniería y Desarrollo</h4>
|
||||||
<ul className="text-sm font-bold text-slate-700 space-y-2">
|
<ul className="text-sm font-bold text-slate-700 space-y-2">
|
||||||
<li className="flex flex-wrap items-center gap-2">
|
<li className="flex flex-wrap items-center justify-center gap-2 sm:justify-start">
|
||||||
Alexis López Moral
|
Alexis López Moral
|
||||||
<span className="text-slate-400 font-medium text-[10px] font-mono bg-slate-50 border border-slate-100 px-1.5 py-0.5 rounded">Frontend</span>
|
<span className="text-slate-400 font-medium text-[10px] font-mono bg-slate-50 border border-slate-100 px-1.5 py-0.5 rounded">Frontend</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex flex-wrap items-center gap-2">
|
<li className="flex flex-wrap items-center justify-center gap-2 sm:justify-start">
|
||||||
Mireya Cueto Garrido
|
Mireya Cueto Garrido
|
||||||
<span className="text-slate-400 font-medium text-[10px] font-mono bg-slate-50 border border-slate-100 px-1.5 py-0.5 rounded">Backend</span>
|
<span className="text-slate-400 font-medium text-[10px] font-mono bg-slate-50 border border-slate-100 px-1.5 py-0.5 rounded">Backend</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -34,29 +34,31 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dirección Científica */}
|
{/* Dirección Científica */}
|
||||||
<div className="lg:col-span-2 flex flex-col">
|
<div className="lg:col-span-2 flex flex-col items-center text-center sm:items-start sm:text-left">
|
||||||
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-3">Dirección Científica</h4>
|
<h4 className="text-[10px] font-black uppercase tracking-[0.2em] text-slate-400 mb-3">Dirección Científica</h4>
|
||||||
<p className="text-sm font-bold text-slate-700">Luis Martínez López</p>
|
<p className="text-sm font-bold text-slate-700">Luis Martínez López</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enlaces Institucionales y Código */}
|
{/* Enlaces Institucionales y Código */}
|
||||||
<div className="lg:col-span-3 flex flex-col gap-5 sm:items-start lg:items-end">
|
<div className="lg:col-span-3 flex flex-col items-center w-full sm:items-start lg:items-end">
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2.5 sm:gap-3 w-full max-w-sm sm:max-w-none lg:flex lg:flex-col lg:w-auto lg:gap-5">
|
||||||
|
|
||||||
{/* Universidad de Jaén */}
|
{/* Universidad de Jaén */}
|
||||||
<a
|
<a
|
||||||
href="https://www.ujaen.es/"
|
href="https://www.ujaen.es/"
|
||||||
target="_blank" rel="noopener noreferrer"
|
target="_blank" rel="noopener noreferrer"
|
||||||
className="group flex items-center gap-3 w-fit"
|
className="group flex items-center justify-center gap-2.5 rounded-lg border border-slate-200 bg-slate-50/50 px-3 py-2 transition-colors hover:bg-slate-100"
|
||||||
title="Ir a la web oficial de la Universidad de Jaén"
|
title="Ir a la web oficial de la Universidad de Jaén"
|
||||||
>
|
>
|
||||||
<div className="text-right border-r-2 border-slate-300 group-hover:border-blue-600 pr-3 flex flex-col justify-center h-9 transition-colors">
|
<div className="flex h-8 flex-col justify-center border-r-2 border-slate-300 pr-2.5 text-right transition-colors group-hover:border-blue-600">
|
||||||
<span className="text-xs font-black text-slate-800 uppercase tracking-widest leading-none mb-1">Universidad</span>
|
<span className="mb-0.5 text-[11px] font-bold uppercase leading-none tracking-wide text-slate-800">Universidad</span>
|
||||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-[0.3em] leading-none">de Jaén</span>
|
<span className="text-[10px] font-medium uppercase leading-none tracking-[0.22em] text-slate-500">de Jaén</span>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
src="/uja-logo.png"
|
src={`${import.meta.env.BASE_URL}uja-logo.png`}
|
||||||
alt="Logo UJA"
|
alt="Logo UJA"
|
||||||
className="w-9 h-9 object-contain grayscale group-hover:grayscale-0 transition-all opacity-80 group-hover:opacity-100"
|
className="h-7 w-7 object-contain grayscale opacity-80 transition-all group-hover:grayscale-0 group-hover:opacity-100"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -64,24 +66,25 @@ export default function Footer() {
|
|||||||
<a
|
<a
|
||||||
href="https://github.com/alexislopez-dev/deck-of-cards"
|
href="https://github.com/alexislopez-dev/deck-of-cards"
|
||||||
target="_blank" rel="noopener noreferrer"
|
target="_blank" rel="noopener noreferrer"
|
||||||
className="group flex items-center gap-3 w-fit"
|
className="group flex items-center justify-center gap-2.5 rounded-lg border border-slate-200 bg-slate-50/50 px-3 py-2 transition-colors hover:bg-slate-100"
|
||||||
title="Ver código fuente en GitHub"
|
title="Ver código fuente en GitHub"
|
||||||
>
|
>
|
||||||
<div className="text-right border-r-2 border-slate-300 group-hover:border-slate-800 pr-3 flex flex-col justify-center h-9 transition-colors">
|
<div className="flex h-8 flex-col justify-center border-r-2 border-slate-300 pr-2.5 text-right transition-colors group-hover:border-slate-800">
|
||||||
<span className="text-xs font-black text-slate-800 uppercase tracking-widest leading-none mb-1">Repositorio</span>
|
<span className="mb-0.5 text-[11px] font-bold uppercase leading-none tracking-wide text-slate-800">Repositorio</span>
|
||||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-[0.3em] leading-none">Oficial</span>
|
<span className="text-[10px] font-medium uppercase leading-none tracking-[0.22em] text-slate-500">Oficial</span>
|
||||||
</div>
|
</div>
|
||||||
<svg className="w-9 h-9 text-slate-400 group-hover:text-slate-800 transition-colors" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
<svg className="h-7 w-7 text-slate-400 transition-colors group-hover:text-slate-800" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sub-Footer: Copyright y Referencia Científica */}
|
{/* Sub-Footer: Copyright y Referencia Científica */}
|
||||||
<div className="mt-6 pt-6 border-t border-slate-100 flex flex-col md:flex-row justify-between items-center gap-4">
|
<div className="mt-6 pt-6 border-t border-slate-100 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest whitespace-nowrap">
|
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest text-center">
|
||||||
© {new Date().getFullYear()} Deck of Cards App.
|
© {new Date().getFullYear()} Deck of Cards App.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] font-medium text-slate-400 text-center md:text-right">
|
<p className="text-[10px] font-medium text-slate-400 text-center md:text-right">
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function Header() {
|
|||||||
className="flex items-center gap-3 whitespace-nowrap transition-opacity hover:opacity-80"
|
className="flex items-center gap-3 whitespace-nowrap transition-opacity hover:opacity-80"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/favicon.svg"
|
src={`${import.meta.env.BASE_URL}favicon.svg`}
|
||||||
alt="Deck of Cards Logo"
|
alt="Deck of Cards Logo"
|
||||||
className="h-10 w-10 rounded-xl object-contain shadow-sm"
|
className="h-10 w-10 rounded-xl object-contain shadow-sm"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Footer from './Footer';
|
|||||||
|
|
||||||
export default function MainLayout({ children }) {
|
export default function MainLayout({ children }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-slate-50 font-sans">
|
<div className="min-h-screen flex flex-col overflow-x-clip bg-slate-50 font-sans">
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export const API_BASE_URL = import.meta.env.VITE_API_URL;
|
import { APP_BASE_PATH } from './lib/paths';
|
||||||
|
|
||||||
|
const configuredApiUrl = import.meta.env.VITE_API_URL;
|
||||||
|
export const API_BASE_URL = configuredApiUrl || (APP_BASE_PATH ? `${APP_BASE_PATH}/api` : '/api');
|
||||||
|
|
||||||
export const CHART_COLORS = [
|
export const CHART_COLORS = [
|
||||||
'#ef4444', '#f59e0b', '#10b981', '#3b82f6',
|
'#ef4444', '#f59e0b', '#10b981', '#3b82f6',
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
/* Solo escanear código fuente; evita que Tailwind/Vite procesen Dockerfile u otros archivos en /app */
|
/* Solo escanear código fuente; evita que Tailwind/Vite procesen Dockerfile u otros archivos en /app */
|
||||||
@source "./src/**/*.{js,jsx}";
|
@source "./src/**/*.{js,jsx}";
|
||||||
|
|
||||||
|
html {
|
||||||
|
overflow-x: clip;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
overflow-x: clip;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import Axios from 'axios';
|
import Axios from 'axios';
|
||||||
import { API_BASE_URL } from '../config';
|
import { API_BASE_URL } from '../config';
|
||||||
|
import { isLoginPath, toAppPath } from './paths';
|
||||||
|
|
||||||
const api = Axios.create({
|
const api = Axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
@@ -28,8 +29,8 @@ api.interceptors.response.use(
|
|||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
|
|
||||||
// SOLUCIÓN: Solo recargamos y redirigimos si NO estamos ya en /login
|
// SOLUCIÓN: Solo recargamos y redirigimos si NO estamos ya en /login
|
||||||
if (window.location.pathname !== '/login') {
|
if (!isLoginPath(window.location.pathname)) {
|
||||||
window.location.href = '/login';
|
window.location.href = toAppPath('/login');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const normalizeBasePath = (value) => {
|
||||||
|
if (!value || value === '/') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const withLeadingSlash = value.startsWith('/') ? value : `/${value}`;
|
||||||
|
return withLeadingSlash.replace(/\/$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const APP_BASE_PATH = normalizeBasePath(import.meta.env.VITE_BASE_PATH);
|
||||||
|
|
||||||
|
export const toAppPath = (path) => {
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${APP_BASE_PATH}${normalizedPath}` || normalizedPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isLoginPath = (pathname) => {
|
||||||
|
const loginPath = toAppPath('/login');
|
||||||
|
return pathname === loginPath || pathname === '/login';
|
||||||
|
};
|
||||||
@@ -3,7 +3,8 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { authService } from '../services/authService';
|
import { authService } from '../services/authService';
|
||||||
import { API_BASE_URL } from '../config';
|
import { API_BASE_URL } from '../config';
|
||||||
import { FiEye, FiEyeOff } from 'react-icons/fi';
|
import { FiArrowLeft, FiEye, FiEyeOff } from 'react-icons/fi';
|
||||||
|
import AuthDemoPanel from '../components/AuthDemoPanel';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -71,9 +72,26 @@ export default function Login() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center py-4">
|
<div className="w-full flex items-start justify-center">
|
||||||
<div className="max-w-md w-full bg-white p-10 rounded-3xl shadow-sm border border-slate-200">
|
<div className="w-full grid gap-6 lg:gap-8 lg:grid-cols-[minmax(0,1fr)_26rem]">
|
||||||
|
|
||||||
|
<div className="hidden lg:flex flex-col justify-start rounded-3xl border border-blue-100 bg-linear-to-br from-blue-50 via-indigo-50 to-sky-50 p-10 self-stretch min-h-0">
|
||||||
|
<p className="text-xs font-black uppercase tracking-[0.2em] text-blue-500">Deck of Cards</p>
|
||||||
|
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-800">Modela y compara de forma visual</h1>
|
||||||
|
<p className="mt-3 text-slate-500 text-sm leading-relaxed">
|
||||||
|
Construye funciones de pertenencia difusa, guarda tu historial y vuelve a trabajar donde lo dejaste.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/editor"
|
||||||
|
className="mt-6 inline-flex w-fit items-center rounded-xl bg-slate-900 px-5 py-3 text-sm font-bold text-white transition-colors hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<FiArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Ir al editor principal
|
||||||
|
</Link>
|
||||||
|
<AuthDemoPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full bg-white p-8 sm:p-10 rounded-3xl shadow-sm border border-slate-200">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h2 className="text-3xl font-black text-slate-800 tracking-tight">Deck of Cards</h2>
|
<h2 className="text-3xl font-black text-slate-800 tracking-tight">Deck of Cards</h2>
|
||||||
<p className="text-slate-500 mt-2">Accede a tu historial y gráficas guardadas</p>
|
<p className="text-slate-500 mt-2">Accede a tu historial y gráficas guardadas</p>
|
||||||
@@ -135,6 +153,8 @@ export default function Login() {
|
|||||||
|
|
||||||
<p className="mt-8 text-center text-sm text-slate-500 font-medium">¿Nuevo por aquí? <Link to="/register" className="text-blue-600 hover:underline font-extrabold">Crea una cuenta</Link></p>
|
<p className="mt-8 text-center text-sm text-slate-500 font-medium">¿Nuevo por aquí? <Link to="/register" className="text-blue-600 hover:underline font-extrabold">Crea una cuenta</Link></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,8 @@ import { useState } from 'react';
|
|||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { authService } from '../services/authService';
|
import { authService } from '../services/authService';
|
||||||
import { FiEye, FiEyeOff } from 'react-icons/fi';
|
import { FiArrowLeft, FiEye, FiEyeOff } from 'react-icons/fi';
|
||||||
|
import AuthDemoPanel from '../components/AuthDemoPanel';
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
@@ -95,9 +96,27 @@ export default function Register() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center py-4">
|
<div className="w-full flex items-start justify-center">
|
||||||
<div className="max-w-md w-full bg-white p-10 rounded-3xl shadow-sm border border-slate-200">
|
<div className="w-full grid gap-6 lg:gap-8 lg:grid-cols-[minmax(0,1fr)_26rem]">
|
||||||
|
<div className="hidden lg:flex flex-col justify-start rounded-3xl border border-indigo-100 bg-linear-to-br from-indigo-50 via-violet-50 to-blue-50 p-10 self-stretch min-h-0">
|
||||||
|
<p className="text-xs font-black uppercase tracking-[0.2em] text-indigo-500">Deck of Cards</p>
|
||||||
|
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-800">
|
||||||
|
Crea tu cuenta y guarda cada modelo
|
||||||
|
</h1>
|
||||||
|
<p className="mt-3 text-slate-500 text-sm leading-relaxed">
|
||||||
|
Registra tus criterios, conserva resultados en el historial y retoma tus análisis cuando quieras.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/editor"
|
||||||
|
className="mt-6 inline-flex w-fit items-center rounded-xl bg-slate-900 px-5 py-3 text-sm font-bold text-white transition-colors hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<FiArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Ir al editor principal
|
||||||
|
</Link>
|
||||||
|
<AuthDemoPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full bg-white p-8 sm:p-10 rounded-3xl shadow-sm border border-slate-200">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h2 className="text-3xl font-black text-slate-800 tracking-tight">
|
<h2 className="text-3xl font-black text-slate-800 tracking-tight">
|
||||||
{verificationRequired ? 'Verifica tu email' : 'Crear Cuenta'}
|
{verificationRequired ? 'Verifica tu email' : 'Crear Cuenta'}
|
||||||
@@ -238,5 +257,6 @@ export default function Register() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { APP_BASE_PATH } from '../lib/paths';
|
||||||
import MainLayout from '../components/layout/MainLayout';
|
import MainLayout from '../components/layout/MainLayout';
|
||||||
import DocEditor from '../pages/DocEditor';
|
import DocEditor from '../pages/DocEditor';
|
||||||
import Login from '../pages/Login';
|
import Login from '../pages/Login';
|
||||||
@@ -18,7 +19,7 @@ function ProtectedHistoryRoute() {
|
|||||||
|
|
||||||
export default function AppRouter() {
|
export default function AppRouter() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router basename={APP_BASE_PATH || undefined}>
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/editor" replace />} />
|
<Route path="/" element={<Navigate to="/editor" replace />} />
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { defineConfig } from 'vite'
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
const basePath = process.env.VITE_BASE_PATH || '/'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: basePath,
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
|||||||
Reference in New Issue
Block a user