Compare commits
10 Commits
2c400543e9
...
28ac986e00
| Author | SHA1 | Date | |
|---|---|---|---|
| 28ac986e00 | |||
| 085fee2b96 | |||
| 63c831f0dd | |||
| 37056a4066 | |||
| b88b3f334f | |||
| cccbe15275 | |||
| 31be326f2c | |||
| 180114ce38 | |||
| c8077e57ef | |||
| f4d46080a6 |
+7
-4
@@ -3,12 +3,15 @@ __pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Variables de entorno (solo ignoramos las locales/sobre-escrituras)
|
||||
# Mantenemos .env y .env.example versionados para compartir la configuración
|
||||
# de producción y el esqueleto de variables. Nunca subimos .env.local.
|
||||
# Variables de entorno (no subir secretos; sí .env.example)
|
||||
.env
|
||||
backend/.env
|
||||
frontend/.env
|
||||
.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)
|
||||
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_SECRET=tu-client-secret
|
||||
# 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
|
||||
# Producción (añade la misma URI en Google Console → Credenciales OAuth):
|
||||
# GOOGLE_REDIRECT_URI=http://tu-servidor:8071/api/auth/google/callback
|
||||
# Producción Sinbad2 (HTTPS vía Apache, prefijo /deckofcards):
|
||||
# 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)
|
||||
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
|
||||
# Con docker-compose en local: http://localhost:8071
|
||||
# Con Vite directo en local: http://localhost:5173
|
||||
# En producción: https://tu-dominio.com
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
# Producción Sinbad2: https://sinbad2.ujaen.es/deckofcards
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -8,4 +8,15 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
FROM base AS development
|
||||
|
||||
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()
|
||||
+29
-8
@@ -1,7 +1,12 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
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.middleware.security_headers import SecurityHeadersMiddleware
|
||||
|
||||
# Routers
|
||||
from api.routers.test_mongo import router as test_mongo_router
|
||||
@@ -20,15 +25,31 @@ from api.routers.google_auth import router as google_auth_router
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(SecurityHeadersMiddleware, settings=settings)
|
||||
|
||||
if settings.is_production:
|
||||
app.add_middleware(
|
||||
TrustedHostMiddleware,
|
||||
allowed_hosts=settings.trusted_hosts,
|
||||
)
|
||||
|
||||
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(value_router, prefix="/api/criteria/doc")
|
||||
|
||||
@@ -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:
|
||||
+3
-1
@@ -1,7 +1,8 @@
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
build:
|
||||
context: ./backend
|
||||
target: development
|
||||
container_name: backend
|
||||
ports:
|
||||
- "8070:8000"
|
||||
@@ -15,6 +16,7 @@ services:
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
target: development
|
||||
container_name: frontend
|
||||
ports:
|
||||
- "8071:5173"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
@@ -17,6 +17,7 @@
|
||||
# - Backend ejecutado fuera de Docker (uvicorn --port 8000):
|
||||
# VITE_API_URL=http://localhost:8000/api
|
||||
#
|
||||
# - Producción (ya definido en .env):
|
||||
# VITE_API_URL=http://sinbad2.ujaen.es:8070/api
|
||||
# - Producción Sinbad2 (HTTPS, prefijo /deckofcards; build en docker-compose.prod.yaml):
|
||||
# VITE_BASE_PATH=/deckofcards/
|
||||
# VITE_API_URL=/deckofcards/api
|
||||
VITE_API_URL=http://localhost:8070/api
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Variables de entorno locales de Vite
|
||||
# (.env y .env.example sí se versionan)
|
||||
# Variables de entorno locales de Vite (no subir secretos; sí .env.example)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
|
||||
+33
-2
@@ -1,5 +1,36 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:20-alpine AS development
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
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">
|
||||
<head>
|
||||
<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" />
|
||||
<title>Deck of Cards</title>
|
||||
</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 }) {
|
||||
return (
|
||||
<div className="flex flex-row items-center justify-center gap-3 w-full z-30 relative mt-4">
|
||||
<label className="text-sm font-bold text-slate-600 uppercase tracking-wide whitespace-nowrap">
|
||||
<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 text-center sm:text-left sm:whitespace-nowrap shrink-0">
|
||||
Nombre del Criterio:
|
||||
</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
|
||||
type="text"
|
||||
placeholder="Ej: Calidad del código"
|
||||
@@ -19,7 +19,7 @@ export default function CriterionInput({ criterionName, setCriterionName, 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
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -14,14 +14,15 @@ export default function Step1BaseScale({
|
||||
const [isZoomActive, setIsZoomActive] = useState(true);
|
||||
const containerRef = 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(() => {
|
||||
const updateMeasurements = () => {
|
||||
if (containerRef.current && tableRef.current) {
|
||||
setDimensions({
|
||||
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 currentScale = isZoomActive && needsZoom ? dynamicScale : 1;
|
||||
|
||||
const isScaledLayout = isZoomActive && needsZoom && currentScale < 1 && dimensions.tableHeight > 0;
|
||||
const scaledViewportHeight = isScaledLayout
|
||||
? dimensions.tableHeight * currentScale + 12
|
||||
: undefined;
|
||||
|
||||
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">
|
||||
<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} />
|
||||
|
||||
<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 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 ref={tableRef} className="flex flex-row items-start relative px-10 overflow-visible">
|
||||
<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 w-full justify-center transition-[height] duration-500 ease-out"
|
||||
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) => (
|
||||
<React.Fragment key={index}>
|
||||
|
||||
@@ -96,7 +108,7 @@ export default function Step1BaseScale({
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,86 +2,89 @@ export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-white border-t border-slate-200 mt-auto shrink-0 w-full pt-8 pb-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-8 lg:gap-6">
|
||||
|
||||
|
||||
{/* Proyecto */}
|
||||
<div className="lg:col-span-4 flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="lg:col-span-4 flex flex-col items-center text-center sm:items-start sm:text-left">
|
||||
<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="px-2 py-1 bg-blue-50 text-blue-700 text-[10px] font-black uppercase tracking-widest rounded-md">
|
||||
Software Científico
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<ul className="text-sm font-bold text-slate-700 space-y-2">
|
||||
<li className="flex flex-wrap items-center gap-2">
|
||||
Alexis López Moral
|
||||
<li className="flex flex-wrap items-center justify-center gap-2 sm:justify-start">
|
||||
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>
|
||||
</li>
|
||||
<li className="flex flex-wrap items-center gap-2">
|
||||
Mireya Cueto Garrido
|
||||
<li className="flex flex-wrap items-center justify-center gap-2 sm:justify-start">
|
||||
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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<p className="text-sm font-bold text-slate-700">Luis Martínez López</p>
|
||||
</div>
|
||||
|
||||
{/* Enlaces Institucionales y Código */}
|
||||
<div className="lg:col-span-3 flex flex-col gap-5 sm:items-start lg:items-end">
|
||||
|
||||
{/* Universidad de Jaén */}
|
||||
<a
|
||||
href="https://www.ujaen.es/"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 w-fit"
|
||||
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">
|
||||
<span className="text-xs font-black text-slate-800 uppercase tracking-widest leading-none mb-1">Universidad</span>
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-[0.3em] leading-none">de Jaén</span>
|
||||
</div>
|
||||
<img
|
||||
src="/uja-logo.png"
|
||||
alt="Logo UJA"
|
||||
className="w-9 h-9 object-contain grayscale group-hover:grayscale-0 transition-all opacity-80 group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
<div className="lg:col-span-3 flex flex-col items-center w-full sm:items-start lg:items-end">
|
||||
|
||||
{/* Repositorio GitHub */}
|
||||
<a
|
||||
href="https://github.com/alexislopez-dev/deck-of-cards"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
className="group flex items-center gap-3 w-fit"
|
||||
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">
|
||||
<span className="text-xs font-black text-slate-800 uppercase tracking-widest leading-none mb-1">Repositorio</span>
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-[0.3em] leading-none">Oficial</span>
|
||||
</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">
|
||||
<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>
|
||||
</a>
|
||||
<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 */}
|
||||
<a
|
||||
href="https://www.ujaen.es/"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
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"
|
||||
>
|
||||
<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="mb-0.5 text-[11px] font-bold uppercase leading-none tracking-wide text-slate-800">Universidad</span>
|
||||
<span className="text-[10px] font-medium uppercase leading-none tracking-[0.22em] text-slate-500">de Jaén</span>
|
||||
</div>
|
||||
<img
|
||||
src={`${import.meta.env.BASE_URL}uja-logo.png`}
|
||||
alt="Logo UJA"
|
||||
className="h-7 w-7 object-contain grayscale opacity-80 transition-all group-hover:grayscale-0 group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
|
||||
{/* Repositorio GitHub */}
|
||||
<a
|
||||
href="https://github.com/alexislopez-dev/deck-of-cards"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
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"
|
||||
>
|
||||
<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="mb-0.5 text-[11px] font-bold uppercase leading-none tracking-wide text-slate-800">Repositorio</span>
|
||||
<span className="text-[10px] font-medium uppercase leading-none tracking-[0.22em] text-slate-500">Oficial</span>
|
||||
</div>
|
||||
<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" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<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.
|
||||
</p>
|
||||
<p className="text-[10px] font-medium text-slate-400 text-center md:text-right">
|
||||
@@ -92,4 +95,4 @@ export default function Footer() {
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function Header() {
|
||||
className="flex items-center gap-3 whitespace-nowrap transition-opacity hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src="/favicon.svg"
|
||||
src={`${import.meta.env.BASE_URL}favicon.svg`}
|
||||
alt="Deck of Cards Logo"
|
||||
className="h-10 w-10 rounded-xl object-contain shadow-sm"
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@ import Footer from './Footer';
|
||||
|
||||
export default function MainLayout({ children }) {
|
||||
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 />
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
'#ef4444', '#f59e0b', '#10b981', '#3b82f6',
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
/* Solo escanear código fuente; evita que Tailwind/Vite procesen Dockerfile u otros archivos en /app */
|
||||
@source "./src/**/*.{js,jsx}";
|
||||
|
||||
html {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: clip;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import Axios from 'axios';
|
||||
import { API_BASE_URL } from '../config';
|
||||
import { isLoginPath, toAppPath } from './paths';
|
||||
|
||||
const api = Axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
@@ -28,8 +29,8 @@ api.interceptors.response.use(
|
||||
localStorage.removeItem('user');
|
||||
|
||||
// SOLUCIÓN: Solo recargamos y redirigimos si NO estamos ya en /login
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
if (!isLoginPath(window.location.pathname)) {
|
||||
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 { authService } from '../services/authService';
|
||||
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() {
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -71,70 +72,89 @@ export default function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center py-4">
|
||||
<div className="max-w-md w-full bg-white p-10 rounded-3xl shadow-sm border border-slate-200">
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<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>
|
||||
<div className="w-full flex items-start justify-center">
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-4 rounded-2xl text-sm font-bold mb-6 border border-red-100 text-center">
|
||||
{error}
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-bold text-slate-700 ml-1">Email</label>
|
||||
<input
|
||||
type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-5 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
|
||||
placeholder="correo@ejemplo.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-bold text-slate-700 ml-1">Contraseña</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
required value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-5 pr-12 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors focus:outline-none"
|
||||
>
|
||||
{showPassword ? (
|
||||
<FiEye className="w-5 h-5" strokeWidth={2} />
|
||||
) : (
|
||||
<FiEyeOff className="w-5 h-5" strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-4 rounded-2xl text-sm font-bold mb-6 border border-red-100 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-bold text-slate-700 ml-1">Email</label>
|
||||
<input
|
||||
type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-5 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
|
||||
placeholder="correo@ejemplo.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-bold text-slate-700 ml-1">Contraseña</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
required value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-5 pr-12 py-3 rounded-2xl border border-slate-200 focus:ring-2 focus:ring-blue-500 outline-none transition-all bg-slate-50 focus:bg-white"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 transition-colors focus:outline-none"
|
||||
>
|
||||
{showPassword ? (
|
||||
<FiEye className="w-5 h-5" strokeWidth={2} />
|
||||
) : (
|
||||
<FiEyeOff className="w-5 h-5" strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl transition-all shadow-sm active:scale-95 mt-2">
|
||||
Entrar
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="relative my-8">
|
||||
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-slate-100"></div></div>
|
||||
<div className="relative flex justify-center text-xs uppercase tracking-widest"><span className="px-3 bg-white text-slate-400 font-bold">O</span></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-2xl transition-all shadow-sm active:scale-95 mt-2">
|
||||
Entrar
|
||||
<button type="button" onClick={handleGoogleLogin} className="w-full flex items-center justify-center gap-3 px-4 py-4 border-2 border-slate-100 rounded-2xl bg-white text-slate-700 font-bold hover:bg-slate-50 hover:border-slate-200 transition-all shadow-sm active:scale-95">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" /><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" /><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" /><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" /></svg>
|
||||
Continuar con Google
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="relative my-8">
|
||||
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-slate-100"></div></div>
|
||||
<div className="relative flex justify-center text-xs uppercase tracking-widest"><span className="px-3 bg-white text-slate-400 font-bold">O</span></div>
|
||||
<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>
|
||||
|
||||
<button type="button" onClick={handleGoogleLogin} className="w-full flex items-center justify-center gap-3 px-4 py-4 border-2 border-slate-100 rounded-2xl bg-white text-slate-700 font-bold hover:bg-slate-50 hover:border-slate-200 transition-all shadow-sm active:scale-95">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4" /><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" /><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" /><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" /></svg>
|
||||
Continuar con Google
|
||||
</button>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
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() {
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -95,34 +96,52 @@ export default function Register() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center py-4">
|
||||
<div className="max-w-md w-full bg-white p-10 rounded-3xl shadow-sm border border-slate-200">
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-black text-slate-800 tracking-tight">
|
||||
{verificationRequired ? 'Verifica tu email' : 'Crear Cuenta'}
|
||||
</h2>
|
||||
<p className="text-slate-500 mt-2">
|
||||
{verificationRequired
|
||||
? `Introduce el código enviado a ${pendingEmail}`
|
||||
: 'Inicia sesión para guardar tu progreso'}
|
||||
<div className="w-full flex items-start justify-center">
|
||||
<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>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-4 rounded-2xl text-sm font-bold mb-6 border border-red-100 text-center">
|
||||
{error}
|
||||
<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">
|
||||
<h2 className="text-3xl font-black text-slate-800 tracking-tight">
|
||||
{verificationRequired ? 'Verifica tu email' : 'Crear Cuenta'}
|
||||
</h2>
|
||||
<p className="text-slate-500 mt-2">
|
||||
{verificationRequired
|
||||
? `Introduce el código enviado a ${pendingEmail}`
|
||||
: 'Inicia sesión para guardar tu progreso'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{infoMessage && (
|
||||
<div className="bg-blue-50 text-blue-700 p-4 rounded-2xl text-sm font-bold mb-6 border border-blue-100 text-center">
|
||||
{infoMessage}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-4 rounded-2xl text-sm font-bold mb-6 border border-red-100 text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!verificationRequired ? (
|
||||
<form onSubmit={handleRegisterSubmit} className="space-y-4">
|
||||
{infoMessage && (
|
||||
<div className="bg-blue-50 text-blue-700 p-4 rounded-2xl text-sm font-bold mb-6 border border-blue-100 text-center">
|
||||
{infoMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!verificationRequired ? (
|
||||
<form onSubmit={handleRegisterSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-bold text-slate-700 ml-1">Nombre de usuario</label>
|
||||
<input
|
||||
@@ -196,9 +215,9 @@ export default function Register() {
|
||||
>
|
||||
{isSubmitting ? 'Enviando código...' : 'Registrarse'}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerificationSubmit} className="space-y-4">
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerificationSubmit} className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-bold text-slate-700 ml-1">Código de verificación</label>
|
||||
<input
|
||||
@@ -230,12 +249,13 @@ export default function Register() {
|
||||
>
|
||||
{isResending ? 'Reenviando...' : 'Reenviar código'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p className="mt-8 text-center text-sm text-slate-500 font-medium">
|
||||
¿Ya tienes cuenta? <Link to="/login" className="text-blue-600 hover:underline font-extrabold">Inicia sesión aquí</Link>
|
||||
</p>
|
||||
<p className="mt-8 text-center text-sm text-slate-500 font-medium">
|
||||
¿Ya tienes cuenta? <Link to="/login" className="text-blue-600 hover:underline font-extrabold">Inicia sesión aquí</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 DocEditor from '../pages/DocEditor';
|
||||
import Login from '../pages/Login';
|
||||
@@ -18,7 +19,7 @@ function ProtectedHistoryRoute() {
|
||||
|
||||
export default function AppRouter() {
|
||||
return (
|
||||
<Router>
|
||||
<Router basename={APP_BASE_PATH || undefined}>
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/editor" replace />} />
|
||||
|
||||
@@ -2,8 +2,11 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
const basePath = process.env.VITE_BASE_PATH || '/'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
base: basePath,
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
|
||||
Reference in New Issue
Block a user