diff --git a/.gitignore b/.gitignore index a8ebeb5..cbfb8f8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,11 +3,15 @@ __pycache__/ *.py[cod] *$py.class -# Variables de entorno -.env -.env* +# 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. +.env.local +.env.*.local +# Override de Docker Compose para desarrollo local (no debe llegar a producción) +docker-compose.override.yml # Configuraciones del editor .vscode/ -.idea/ \ No newline at end of file +.idea/ diff --git a/README.md b/README.md index 2f10aad..60714e5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -# Deck of Cards image - - +# Deck of Cards Logo de Deck of Cards
@@ -28,11 +26,76 @@ Incluye: --- +# 📸 Capturas de Pantalla + +## 🛠️ Proceso de Modelado (3 Pasos) +El núcleo de la aplicación guía al experto a través de tres fases intuitivas para transformar cartas físicas en modelos matemáticos. + + + + + +
+

Paso 1: Definición de la Escala Base

+ Paso 1 +
+ + + + + +
+

Paso 2: Modelado Difuso

+ Paso 2 - Gráfica +

+

Paso 2: Definición de Subescalas e Intervalos

+ Paso 2 - Subescalas +
+ + + + + +
+

Paso 3: Visualización de la Función Final

+ Paso 3 +
+ +## 📂 Historial + + + + + +
+

Listado del Historial de Modelos

+ Historial 1 +

+

Vista de Detalle de un Modelo Guardado

+ Historial 2 +
+ +## 🔐 Acceso a la Plataforma + + + + + + +
+

Login

+ Login +
+

Registro

+ Registro +
+ +--- + # ⚡ 0. ¿En qué consiste? -**Deck of Cards – TFG** es una herramienta completa diseñada para construir, validar y evaluar **funciones de pertenencia difusas** mediante el método **Deck of Cards (DoC)**, tanto en su versión **T1MF** (tipo-1) como **IT2MF** (tipo-2 intervalar). +**Deck of Cards** es una herramienta completa diseñada para construir, validar y evaluar **funciones de pertenencia difusas** mediante el método **Deck of Cards (DoC)**, tanto en su versión **T1MF** (tipo-1) como **IT2MF** (tipo-2 intervalar). -> [!INFO] > El sistema combina un backend robusto en **FastAPI + MongoDB**, un frontend moderno en **React + Vite**, autenticación por email y **Google OAuth 2.0**, y un sistema de historial por usuario para guardar y recuperar trabajos anteriores. --- @@ -111,6 +174,13 @@ docker compose up --build >- Frontend (react) → http://localhost:5173 >- Base de Datos (mongodb) → puerto 27017 +> [!NOTE] +> **Nota para desarrolladores:** Gracias a Docker, no necesitas instalar nada localmente para que la app funcione. Sin embargo, para que tu editor de código reconozca las librerías, tenga autocompletado y no muestre errores de importación, te recomendamos entrar a las carpetas y descargar las dependencias en tu máquina local: +> ```bash +> cd frontend && npm install +> cd ../backend && pip install -r requirements.txt +> ``` + --- # 🔌 3. Endpoints principales del proyecto @@ -148,11 +218,12 @@ deck-of-cards/ │ ├── routers/ │ ├── models/ │ ├── utils/ -│ ├── .env +│ ├── .env → !!! │ └── main.py │ ├── frontend/ → Vite + React │ ├── src/ +│ ├── .env → !!! │ └── ... │ ├── docker-compose.yaml @@ -161,7 +232,28 @@ deck-of-cards/ --- -# 🔐 5. Configuración del archivo .env +# 🔐 5. Configuración de los archivos .env + +El frontend necesita un archivo `.env` para especificar el puerto del backend + +📍 **Este archivo debe estar dentro de la carpeta `frontend/`**, así: + +``` +deck-of-cards/ +│ +├── frontend/ +│ ├── src/ +│ ├── .env ← AQUÍ +│ └── ... +``` + +Contenido obligatorio del `.env`: + +``` +VITE_API_URL=http://localhost:8000/api +``` + +Por otro lado, El backend necesita un archivo `.env` para funcionar correctamente, especialmente para el login con Google. @@ -177,7 +269,7 @@ deck-of-cards/ │ └── ... ``` -Contenido mínimo del `.env`: +Contenido obligatorio del `.env`: ``` GOOGLE_CLIENT_ID=tu_client_id_de_google //id del cliente, debe ser el mismo del proyecto de google cloud. @@ -200,37 +292,21 @@ Un ejemplo de página de donde sacar las tres primeras claves: Para activar el login con Google, debes crear credenciales OAuth 2.0. Para ello sigue estos pasos: -### 🟦 Paso 1 — Entra en Google Cloud Console +### 🟦 Paso 1 — Entra en Google Cloud Console y haz login https://console.cloud.google.com ### 🟩 Paso 2 — Crea un proyecto -Menú superior → “Seleccionar proyecto” → “Nuevo proyecto”. +Menú superior → “Seleccionar proyecto” → “Nuevo proyecto” → Elige un nombre para tu proyecto. -### 🟧 Paso 3 — Configura la pantalla de consentimiento OAuth - -image - - -En el buscador escribe: **OAuth consent screen** ó ve a la página que se muestra en la captura superior. - -Selecciona **Información de la página**: - -→ Rellena los datos necesarios (no hace falta poner un dominio si lo tienes en local) → Guardar - - -image - - -### 🟨 Paso 4 — Crea las credenciales OAuth -Menú lateral: -**APIs & Services → Credentials → Create Credentials → OAuth Client ID** +### 🟨 Paso 3 — Crea las credenciales OAuth +En el menú lateral: +**APIs y Servicios → Credenciales → Crear Credenciales → ID de Cliente OAuth** image - - +image Tipo de aplicación: -✔ **Web application** +✔ **Aplicación Web** Añade en `Orígenes autorizados de javascript`: http://localhost:8000 @@ -238,16 +314,37 @@ http://localhost:8000 Añade este Redirect URI obligatorio: http://localhost:8000/api/auth/google/callback +Después te saldrá una ventana para descargarte las claves en formato json (hazlo para que no se pierdan) + +Finalmente podrás ver la pantalla final (desde aquí no se puede acceder al secreto del cliente, solo puedes generar uno nuevo): + image - -### 🟥 Paso 5 — Copia tus claves +### 🟧 Paso 4 — Copia tus claves Google te mostrará: - **Client ID** - **Client Secret** -Pégalos en tu `.env` dentro de `backend/`. Asegúrate de que las copias correctamente. +Pégalos en tu `.env` dentro de `backend/`. Asegúrate de que las copias correctamente cada una en su lugar. + +### 🟥 Paso 5 — Configura la pantalla de consentimiento OAuth + +En el buscador escribe: **OAuth consent screen** o busca en el menu lateral la siguiente opción: + +image + +Configura los datos de tu proyecto seleccionando **Información de la página**: + +→ Rellena los datos necesarios (no hace falta poner un dominio si lo tienes en local) → Guardar + +image + +### 🟪 Paso 6 — Publicar la app + +Publica tu app accediendo a: (Este paso es muy importante para permitir acceder a cualquier correo) + +image --- @@ -261,8 +358,21 @@ Con esto, ya puedes: - Levantar el sistema con Docker - Usar login normal y login con Google -Y ... +Y... ¡Proyecto listo para ejecutarse en local! -¡Proyecto listo para ejecutarse en local! +## 👥 Autores y Equipo -Gracias por llegar hasta aquí ;) +Este proyecto es fruto de la colaboración académica con la **Universidad de Jaén**. + +| Rol | Desarrollador | GitHub | +| :--- | :--- | :--- | +| **Frontend** | Alexis López Moral | [@AlexisLopez-Dev](https://github.com/AlexisLopez-Dev) | +| **Backend** | Mireya Cueto Garrido | [@MireyaCueto](https://github.com/MireyaCueto) | + +### 🎓 Dirección y Tutorización +* **Director del proyecto:** Luis Martínez López + +--- +

+ Realizado con ❤️ en la Universidad de Jaén +

diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..ec7a048 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,27 @@ +# Backend environment variables (FastAPI) +# ---------------------------------------- +# Copia este archivo como `.env` para desarrollo local y rellena los valores. +# +# docker-compose levanta el backend con `env_file: backend/.env`. + +# Google OAuth (https://console.cloud.google.com/apis/credentials) +# IMPORTANTE: la REDIRECT_URI es la URL a la que Google devuelve al usuario, +# por tanto debe coincidir con la URL pública del backend tal como la ve el +# navegador. Usando docker-compose, el backend está expuesto en el host en +# el puerto 8070, así que: +# http://localhost:8070/api/auth/google/callback +# Si ejecutas el backend fuera de Docker en el puerto 8000, usa: +# http://localhost:8000/api/auth/google/callback +# Esta URI debe estar registrada también en la consola de Google Cloud. +GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=tu-client-secret +GOOGLE_REDIRECT_URI=http://localhost:8070/api/auth/google/callback + +# Clave para firmar los JWT (usa algo largo y aleatorio en producción) +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 diff --git a/backend/api/routers/google_auth.py b/backend/api/routers/google_auth.py index 2769868..1c787b8 100644 --- a/backend/api/routers/google_auth.py +++ b/backend/api/routers/google_auth.py @@ -13,6 +13,7 @@ GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET") REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI") SECRET_KEY = os.getenv("SECRET_KEY") +FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") @router.get("/login") async def google_login(): @@ -97,4 +98,4 @@ async def google_callback(request: Request): {"$set": {"token": token}} ) - return RedirectResponse(f"http://localhost:5173/login?token={token}") \ No newline at end of file + return RedirectResponse(f"{FRONTEND_URL}/login?token={token}") \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index aead62e..48211f1 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,26 +4,30 @@ services: context: ./backend container_name: backend ports: - - "8000:8000" + - "8070:8000" volumes: - ./backend:/app depends_on: - db env_file: - - backend\.env + - backend/.env frontend: build: context: ./frontend container_name: frontend ports: - - "5173:5173" + - "8071:5173" volumes: - ./frontend:/app - /app/node_modules + environment: + - VITE_API_URL=http://localhost:8070/api + depends_on: + - backend db: - image: mongo:6 + image: mongo:4.4 container_name: mongo restart: always ports: diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..3c23460 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,22 @@ +# Frontend environment variables (Vite) +# -------------------------------------- +# Copia este archivo como `.env.local` para desarrollo local. +# Vite carga automáticamente `.env.local` encima de `.env`, así que +# los valores aquí definidos sobrescribirán los de producción en dev. +# +# cp .env.example .env.local +# +# IMPORTANTE: Sólo las variables con prefijo VITE_ se exponen al cliente. + +# URL base de la API del backend (la ve el NAVEGADOR, no el contenedor). +# +# - Dev con docker-compose (recomendado): el backend está expuesto en el +# puerto 8070 del host (mapea 8070 -> 8000 del contenedor). +# VITE_API_URL=http://localhost:8070/api +# +# - 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 +VITE_API_URL=http://localhost:8070/api diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..efe0071 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,11 @@ dist dist-ssr *.local +# Variables de entorno locales de Vite +# (.env y .env.example sí se versionan) +.env.local +.env.*.local + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/src/components/membershipFunction/Controls.jsx b/frontend/src/components/membershipFunction/Controls.jsx index 565cc84..252a6fb 100644 --- a/frontend/src/components/membershipFunction/Controls.jsx +++ b/frontend/src/components/membershipFunction/Controls.jsx @@ -1,4 +1,105 @@ -export default function Controls({ +import { useState } from 'react'; + +function SliderInput({ label, value, min, max, step, color, onChange, decimals = 3 }) { + const [draft, setDraft] = useState(null); + + const numericValue = Number(value); + const displayValue = draft !== null ? draft : numericValue.toFixed(decimals); + + const commitDraft = () => { + if (draft === null) return; + + const trimmed = draft.trim(); + if (trimmed === '' || trimmed === '-' || trimmed === '.' || trimmed === '-.') { + setDraft(null); + return; + } + + const parsed = parseFloat(trimmed); + if (Number.isNaN(parsed)) { + setDraft(null); + return; + } + + const clamped = Math.min(max, Math.max(min, parsed)); + onChange(clamped); + setDraft(null); + }; + + const handleNumberChange = (e) => { + const raw = e.target.value; + + if (raw === '') { + setDraft(''); + return; + } + + const num = e.target.valueAsNumber; + + if (Number.isNaN(num)) { + setDraft(raw); + return; + } + + if (num > max) { + setDraft(max.toFixed(decimals)); + onChange(max); + return; + } + + if (num < min) { + setDraft(raw); + return; + } + + setDraft(raw); + onChange(num); + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } else if (e.key === 'Escape') { + setDraft(null); + e.currentTarget.blur(); + } + }; + + return ( +
+ +
+ onChange(e.target.value)} + className="flex-1 cursor-pointer h-1.5" + style={{ accentColor: color }} + /> + +
+
+ ); +} + +const MF_STEP = 0.001; +const snapToMfStep = (v) => Math.round(Number(v) / MF_STEP) * MF_STEP; + +export default function Controls({ selectedTerm, currentMf, selectedColor, baseScale, mfDefinitions, updateCurrentMf, subscales, onOpenSubscale }) { @@ -6,38 +107,50 @@ export default function Controls({ const scaleKeys = Object.keys(baseScale); const selectedIndex = scaleKeys.indexOf(selectedTerm); - - let absoluteMin = 0, absoluteMax = 1; - if (selectedIndex > 0) absoluteMin = mfDefinitions[scaleKeys[selectedIndex - 1]].coreEnd; - if (selectedIndex < scaleKeys.length - 1) absoluteMax = mfDefinitions[scaleKeys[selectedIndex + 1]].coreStart; + + const prevTerm = selectedIndex > 0 ? mfDefinitions[scaleKeys[selectedIndex - 1]] : null; + const nextTerm = selectedIndex < scaleKeys.length - 1 ? mfDefinitions[scaleKeys[selectedIndex + 1]] : null; + + const anchor = snapToMfStep(baseScale[selectedTerm]); + + const bounds = { + supportStart: { min: snapToMfStep(prevTerm?.coreEnd ?? 0), max: anchor }, + coreStart: { min: snapToMfStep(prevTerm?.supportEnd ?? 0), max: anchor }, + coreEnd: { min: anchor, max: snapToMfStep(nextTerm?.supportStart ?? 1) }, + supportEnd: { min: anchor, max: snapToMfStep(nextTerm?.coreStart ?? 1) }, + }; const leftSubscale = subscales?.[selectedTerm]?.left; const rightSubscale = subscales?.[selectedTerm]?.right; + const commonProps = { step: 0.001, color: selectedColor }; + return (

Ajustando: "{selectedTerm}"

- +
-
- - updateCurrentMf('supportStart', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor, opacity: 0.7 }} /> -
-
- - updateCurrentMf('coreStart', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} /> -
- + updateCurrentMf('supportStart', v)} + /> + updateCurrentMf('coreStart', v)} + /> +
-
-
- - updateCurrentMf('supportEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor, opacity: 0.7 }} /> -
-
- - updateCurrentMf('coreEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} /> -
+ updateCurrentMf('supportEnd', v)} + /> + updateCurrentMf('coreEnd', v)} + />
-
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/DocEditor.jsx b/frontend/src/pages/DocEditor.jsx index 8e318ab..0e18b1b 100644 --- a/frontend/src/pages/DocEditor.jsx +++ b/frontend/src/pages/DocEditor.jsx @@ -5,6 +5,15 @@ import SubscaleModal from '../components/editor/SubscaleModal'; import { calculateValueFunction, buildFuzzyGraph, saveToHistory } from '../services/docService'; import Step3FinalGraph from '../components/editor/Step3FinalGraph'; +// Step de la rejilla numérica de los puntos de la función de pertenencia. +// El de Controls usa este mismo step (0.001), así que +// guardamos SIEMPRE en este grid: evita que valores 4-decimales del backend +// (p.ej. 0.3017) contaminen el estado, lo cual disparaba dos bugs en el UI: +// · stepMismatch en hover ("los valores válidos más aproximados son…") +// · flechas que no llegan al máximo (paraban en 0.9997 con max=1). +const MF_STEP = 0.001; +const snapToMfStep = (v) => Math.round(Number(v) / MF_STEP) * MF_STEP; + export default function DocEditor() { const [step, setStep] = useState(1); const [isLoading, setIsLoading] = useState(false); @@ -46,7 +55,10 @@ export default function DocEditor() { const baseResult = await calculateValueFunction(payloadBase); setBaseScale(baseResult.values); const initialMfs = {}; - Object.entries(baseResult.values).forEach(([name, value]) => { initialMfs[name] = { supportStart: value, coreStart: value, coreEnd: value, supportEnd: value }; }); + Object.entries(baseResult.values).forEach(([name, value]) => { + const v = snapToMfStep(value); + initialMfs[name] = { supportStart: v, coreStart: v, coreEnd: v, supportEnd: v }; + }); setMfDefinitions(initialMfs); setSelectedTerm(Object.keys(baseResult.values)[0]); setStep(2); @@ -56,22 +68,24 @@ export default function DocEditor() { // MANEJADORES: FASE 2 const updateCurrentMf = (field, value) => { if (!selectedTerm) return; - let numValue = parseFloat(value); + // Snap al grid de 3 decimales antes de hacer cualquier comparación, + // para que el estado nunca guarde más precisión que la que muestra el input. + let numValue = snapToMfStep(value); setMfDefinitions(prev => { const scaleKeys = Object.keys(baseScale); const selectedIndex = scaleKeys.indexOf(selectedTerm); let prevCoreEnd = 0, prevSupportEnd = 0, nextCoreStart = 1, nextSupportStart = 1; if (selectedIndex > 0) { - prevCoreEnd = prev[scaleKeys[selectedIndex - 1]].coreEnd; - prevSupportEnd = prev[scaleKeys[selectedIndex - 1]].supportEnd; + prevCoreEnd = snapToMfStep(prev[scaleKeys[selectedIndex - 1]].coreEnd); + prevSupportEnd = snapToMfStep(prev[scaleKeys[selectedIndex - 1]].supportEnd); } if (selectedIndex < scaleKeys.length - 1) { - nextCoreStart = prev[scaleKeys[selectedIndex + 1]].coreStart; - nextSupportStart = prev[scaleKeys[selectedIndex + 1]].supportStart; + nextCoreStart = snapToMfStep(prev[scaleKeys[selectedIndex + 1]].coreStart); + nextSupportStart = snapToMfStep(prev[scaleKeys[selectedIndex + 1]].supportStart); } - const anchor = baseScale[selectedTerm]; + const anchor = snapToMfStep(baseScale[selectedTerm]); if (field === 'supportStart' && numValue < prevCoreEnd) numValue = prevCoreEnd; if (field === 'coreStart' && numValue < prevSupportEnd) numValue = prevSupportEnd; diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index a119649..a7d012f 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; 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'; export default function Login() { @@ -66,7 +67,7 @@ export default function Login() { }; const handleGoogleLogin = () => { - window.location.href = "http://localhost:8000/api/auth/google/login"; + window.location.href = `${API_BASE_URL}/auth/google/login`; }; return ( diff --git a/frontend/vite.config.js b/frontend/vite.config.js index db28ef1..5f5a94c 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -10,6 +10,6 @@ export default defineConfig({ ], server: { host: '0.0.0.0', - allowedHosts: 'all' + allowedHosts: true } })