Compare commits

...

10 Commits

Author SHA1 Message Date
Mireya Cueto Garrido 28ac986e00 Ignorar .env en la raiz del proyecto 2026-06-04 12:10:31 +02:00
Mireya Cueto Garrido 085fee2b96 Excluir secretos y CI de GitLab del repositorio publico 2026-06-04 12:04:54 +02:00
alexis 63c831f0dd Merge branch 'style/footer' into 'main'
Refactor layout and styling across components for improved responsiveness

See merge request fjmimbre/deck-of-cards!4
2026-06-03 09:07:36 +00:00
Alexis 37056a4066 Merge branch 'main' of http://serezade.ujaen.es:8030/fjmimbre/deck-of-cards into style/footer 2026-06-03 11:06:02 +02:00
Alexis b88b3f334f Refactor layout and styling across components for improved responsiveness and user experience. Adjusted overflow properties in CSS, enhanced layout in CriterionInput and Step1BaseScale components, and updated Footer for better alignment and accessibility. 2026-06-03 11:03:58 +02:00
Mireya Cueto Garrido cccbe15275 Enable HTTPS production deployment on Sinbad2 via Apache reverse proxy. 2026-06-03 10:41:02 +02:00
alexis 31be326f2c Merge branch 'feature/style-fix' into 'main'
Added demo visualization in Login and Register

See merge request fjmimbre/deck-of-cards!2
2026-05-29 09:20:43 +00:00
Alexis 180114ce38 Update AuthDemoPanel component: refine comments for clarity, adjust 'Alto' values in STEP3_TERMS, and enhance layout of Step2Content for better user experience. 2026-05-29 10:49:40 +02:00
Alexis c8077e57ef Refactor Login and Register components to streamline UI and enhance user experience. Removed unused demo visualization from Login and integrated AuthDemoPanel into Register for improved user guidance. 2026-05-29 10:38:34 +02:00
Alexis f4d46080a6 Enhance Login and Register components with new UI elements and improved layout. Added demo visualization in Login and refined registration flow in Register, including error handling and user guidance. 2026-05-28 12:37:07 +02:00
31 changed files with 883 additions and 176 deletions
+7 -4
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
__pycache__
*.pyc
.env
.venv
venv
+11 -5
View File
@@ -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
View File
@@ -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"]
View File
+41
View File
@@ -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
View File
@@ -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")
View File
@@ -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
+39
View File
@@ -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
View File
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
.env.local
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+25
View File
@@ -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;
}
}
+407
View File
@@ -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>
);
}
+4 -4
View File
@@ -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>
+50 -47
View File
@@ -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>
);
}
}
+1 -1
View File
@@ -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 />
+4 -1
View File
@@ -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',
+5
View File
@@ -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;
}
+3 -2
View File
@@ -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');
}
}
+19
View File
@@ -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';
};
+74 -54
View File
@@ -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>
);
}
}
+51 -31
View File
@@ -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>
);
+2 -1
View File
@@ -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 />} />
+3
View File
@@ -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(),