Merge pull request #47 from uja-dev-practices/feature/input-manual

Feature/input manual
This commit is contained in:
Mireya Cueto Garrido
2026-04-28 10:13:40 +02:00
committed by GitHub
8 changed files with 229 additions and 43 deletions
+7 -1
View File
@@ -3,8 +3,14 @@ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
# Variables de entorno # 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 # Configuraciones del editor
.vscode/ .vscode/
+21
View File
@@ -0,0 +1,21 @@
# 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
+2
View File
@@ -21,6 +21,8 @@ services:
volumes: volumes:
- ./frontend:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
depends_on:
- backend
db: db:
image: mongo:4.4 image: mongo:4.4
+22
View File
@@ -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
+5
View File
@@ -12,6 +12,11 @@ dist
dist-ssr dist-ssr
*.local *.local
# Variables de entorno locales de Vite
# (.env y .env.example sí se versionan)
.env.local
.env.*.local
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
@@ -1,3 +1,104 @@
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 (
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">{label}</label>
<div className="flex items-center gap-3">
<input
type="range"
min={min}
max={max}
step={step}
value={numericValue}
onChange={(e) => onChange(e.target.value)}
className="flex-1 cursor-pointer h-1.5"
style={{ accentColor: color }}
/>
<input
type="number"
min={min}
max={max}
step={step}
value={displayValue}
onChange={handleNumberChange}
onBlur={commitDraft}
onKeyDown={handleKeyDown}
className="w-20 px-2 py-1 text-xs font-semibold text-slate-700 bg-white border border-slate-200 rounded-md text-center shadow-sm focus:outline-none focus:ring-2 focus:border-transparent transition-shadow tabular-nums"
style={{ '--tw-ring-color': color, borderColor: draft !== null ? color : undefined }}
/>
</div>
</div>
);
}
const MF_STEP = 0.001;
const snapToMfStep = (v) => Math.round(Number(v) / MF_STEP) * MF_STEP;
export default function Controls({ export default function Controls({
selectedTerm, currentMf, selectedColor, baseScale, mfDefinitions, updateCurrentMf, selectedTerm, currentMf, selectedColor, baseScale, mfDefinitions, updateCurrentMf,
subscales, onOpenSubscale subscales, onOpenSubscale
@@ -7,13 +108,23 @@ export default function Controls({
const scaleKeys = Object.keys(baseScale); const scaleKeys = Object.keys(baseScale);
const selectedIndex = scaleKeys.indexOf(selectedTerm); const selectedIndex = scaleKeys.indexOf(selectedTerm);
let absoluteMin = 0, absoluteMax = 1; const prevTerm = selectedIndex > 0 ? mfDefinitions[scaleKeys[selectedIndex - 1]] : null;
if (selectedIndex > 0) absoluteMin = mfDefinitions[scaleKeys[selectedIndex - 1]].coreEnd; const nextTerm = selectedIndex < scaleKeys.length - 1 ? mfDefinitions[scaleKeys[selectedIndex + 1]] : null;
if (selectedIndex < scaleKeys.length - 1) absoluteMax = mfDefinitions[scaleKeys[selectedIndex + 1]].coreStart;
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 leftSubscale = subscales?.[selectedTerm]?.left;
const rightSubscale = subscales?.[selectedTerm]?.right; const rightSubscale = subscales?.[selectedTerm]?.right;
const commonProps = { step: 0.001, color: selectedColor };
return ( return (
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-md relative overflow-hidden"> <div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-md relative overflow-hidden">
<div className="absolute top-0 left-0 w-full h-1.5" style={{ backgroundColor: selectedColor }}></div> <div className="absolute top-0 left-0 w-full h-1.5" style={{ backgroundColor: selectedColor }}></div>
@@ -23,18 +134,20 @@ export default function Controls({
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-4 bg-slate-50 p-4 rounded-xl border border-slate-100"> <div className="space-y-4 bg-slate-50 p-4 rounded-xl border border-slate-100">
<div> <SliderInput
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1"> {...commonProps}
<span>Inicio del Soporte (Punto inferior)</span><span style={{ color: selectedColor }}>{currentMf.supportStart.toFixed(3)}</span> {...bounds.supportStart}
</label> label="Inicio del Soporte (Punto inferior)"
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.supportStart} onChange={(e) => updateCurrentMf('supportStart', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor, opacity: 0.7 }} /> value={currentMf.supportStart}
</div> onChange={(v) => updateCurrentMf('supportStart', v)}
<div> />
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1"> <SliderInput
<span>Inicio del Núcleo (Punto superior)</span><span style={{ color: selectedColor }}>{currentMf.coreStart.toFixed(3)}</span> {...commonProps}
</label> {...bounds.coreStart}
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.coreStart} onChange={(e) => updateCurrentMf('coreStart', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} /> label="Inicio del Núcleo (Punto superior)"
</div> value={currentMf.coreStart}
onChange={(v) => updateCurrentMf('coreStart', v)}
/>
<div className="pt-2 border-t border-slate-200 flex justify-end"> <div className="pt-2 border-t border-slate-200 flex justify-end">
<button <button
@@ -47,18 +160,20 @@ export default function Controls({
</div> </div>
<div className="space-y-4 bg-slate-50 p-4 rounded-xl border border-slate-100"> <div className="space-y-4 bg-slate-50 p-4 rounded-xl border border-slate-100">
<div> <SliderInput
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1"> {...commonProps}
<span>Fin del Soporte (Punto inferior)</span><span style={{ color: selectedColor }}>{currentMf.supportEnd.toFixed(3)}</span> {...bounds.supportEnd}
</label> label="Fin del Soporte (Punto inferior)"
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.supportEnd} onChange={(e) => updateCurrentMf('supportEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor, opacity: 0.7 }} /> value={currentMf.supportEnd}
</div> onChange={(v) => updateCurrentMf('supportEnd', v)}
<div> />
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1"> <SliderInput
<span>Fin del Núcleo (Punto superior)</span><span style={{ color: selectedColor }}>{currentMf.coreEnd.toFixed(3)}</span> {...commonProps}
</label> {...bounds.coreEnd}
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.coreEnd} onChange={(e) => updateCurrentMf('coreEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} /> label="Fin del Núcleo (Punto superior)"
</div> value={currentMf.coreEnd}
onChange={(v) => updateCurrentMf('coreEnd', v)}
/>
<div className="pt-2 border-t border-slate-200 flex justify-end"> <div className="pt-2 border-t border-slate-200 flex justify-end">
<button <button
+21 -7
View File
@@ -5,6 +5,15 @@ import SubscaleModal from '../components/editor/SubscaleModal';
import { calculateValueFunction, buildFuzzyGraph, saveToHistory } from '../services/docService'; import { calculateValueFunction, buildFuzzyGraph, saveToHistory } from '../services/docService';
import Step3FinalGraph from '../components/editor/Step3FinalGraph'; import Step3FinalGraph from '../components/editor/Step3FinalGraph';
// Step de la rejilla numérica de los puntos de la función de pertenencia.
// El <input type="number"> 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() { export default function DocEditor() {
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -46,7 +55,10 @@ export default function DocEditor() {
const baseResult = await calculateValueFunction(payloadBase); const baseResult = await calculateValueFunction(payloadBase);
setBaseScale(baseResult.values); setBaseScale(baseResult.values);
const initialMfs = {}; 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); setMfDefinitions(initialMfs);
setSelectedTerm(Object.keys(baseResult.values)[0]); setSelectedTerm(Object.keys(baseResult.values)[0]);
setStep(2); setStep(2);
@@ -56,22 +68,24 @@ export default function DocEditor() {
// MANEJADORES: FASE 2 // MANEJADORES: FASE 2
const updateCurrentMf = (field, value) => { const updateCurrentMf = (field, value) => {
if (!selectedTerm) return; 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 => { setMfDefinitions(prev => {
const scaleKeys = Object.keys(baseScale); const scaleKeys = Object.keys(baseScale);
const selectedIndex = scaleKeys.indexOf(selectedTerm); const selectedIndex = scaleKeys.indexOf(selectedTerm);
let prevCoreEnd = 0, prevSupportEnd = 0, nextCoreStart = 1, nextSupportStart = 1; let prevCoreEnd = 0, prevSupportEnd = 0, nextCoreStart = 1, nextSupportStart = 1;
if (selectedIndex > 0) { if (selectedIndex > 0) {
prevCoreEnd = prev[scaleKeys[selectedIndex - 1]].coreEnd; prevCoreEnd = snapToMfStep(prev[scaleKeys[selectedIndex - 1]].coreEnd);
prevSupportEnd = prev[scaleKeys[selectedIndex - 1]].supportEnd; prevSupportEnd = snapToMfStep(prev[scaleKeys[selectedIndex - 1]].supportEnd);
} }
if (selectedIndex < scaleKeys.length - 1) { if (selectedIndex < scaleKeys.length - 1) {
nextCoreStart = prev[scaleKeys[selectedIndex + 1]].coreStart; nextCoreStart = snapToMfStep(prev[scaleKeys[selectedIndex + 1]].coreStart);
nextSupportStart = prev[scaleKeys[selectedIndex + 1]].supportStart; 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 === 'supportStart' && numValue < prevCoreEnd) numValue = prevCoreEnd;
if (field === 'coreStart' && numValue < prevSupportEnd) numValue = prevSupportEnd; if (field === 'coreStart' && numValue < prevSupportEnd) numValue = prevSupportEnd;
+2 -1
View File
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { authService } from '../services/authService'; import { authService } from '../services/authService';
import { API_BASE_URL } from '../config';
import { FiEye, FiEyeOff } from 'react-icons/fi'; import { FiEye, FiEyeOff } from 'react-icons/fi';
export default function Login() { export default function Login() {
@@ -66,7 +67,7 @@ export default function Login() {
}; };
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
window.location.href = "http://localhost:8000/api/auth/google/login"; window.location.href = `${API_BASE_URL}/auth/google/login`;
}; };
return ( return (