Add: full functionality

This commit is contained in:
Jose
2026-02-14 19:13:52 +01:00
parent bb73e1f564
commit f0abb85b1f
54 changed files with 2211 additions and 157 deletions

14
src/api/axiosInstance.js Normal file
View File

@@ -0,0 +1,14 @@
import axios from "axios";
const createAxiosInstance = (baseURL, token) => {
const instance = axios.create({
baseURL,
headers: {
...(token && { Authorization: `Bearer ${token}` }),
},
});
return instance;
};
export default createAxiosInstance;

View File

@@ -0,0 +1,130 @@
// AccountCard.jsx
import { useEffect, useState } from "react";
import PropTypes from "prop-types";
import dayjs from "dayjs";
import { CONSTANTS } from "@/util/constants.js";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPen, faCheck, faXmark } from "@fortawesome/free-solid-svg-icons";
const getServiceName = (serviceId) => {
switch (serviceId) {
case CONSTANTS.CORE_ID: return "Miarma";
case CONSTANTS.HUERTOS_ID: return "Huertos Bellavista";
case CONSTANTS.MINECRAFT_ID: return "MiarmaCraft";
case CONSTANTS.CINE_ID: return "Huertos de Cine";
case CONSTANTS.MPASTE_ID: return "MPaste";
default: return "Desconocido";
}
};
const AccountCard = ({ identity, onUpdate, onRequestStatusChange, confirmedStatusChange }) => {
const [editMode, setEditMode] = useState(false);
const [formData, setFormData] = useState({
username: identity.username,
email: identity.email,
status: identity.status
});
useEffect(() => {
if (!editMode) {
setFormData({
username: identity.username,
email: identity.email,
status: identity.status
});
}
}, [identity, editMode]);
// Aplica la desactivación confirmada
useEffect(() => {
if (confirmedStatusChange?.credentialId === identity.credentialId) {
setFormData(prev => ({ ...prev, status: confirmedStatusChange.status }));
}
}, [confirmedStatusChange, identity.credentialId]);
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSave = () => {
onUpdate?.({ ...identity, ...formData });
setEditMode(false);
};
const handleCancel = () => setEditMode(false);
return (
<div className="card shadow-sm h-100">
<div className="card-header d-flex justify-content-between align-items-center">
<div className="m-0 p-0 d-flex flex-column">
<span className={`fw-semibold ${identity.status == 0 ? "text-danger" : ""}`}>
{getServiceName(identity.serviceId)}
{identity.status == 0 && (
<span className="text-danger small">
&nbsp;&nbsp;(Se eliminará en 2 meses tras desactivación)
</span>
)}
</span>
<small className="muted">{identity.credentialId}</small>
</div>
<div className="d-flex gap-3">
{identity.status != 0 && (
!editMode ? (
<FontAwesomeIcon icon={faPen} className="cursor-pointer" onClick={() => setEditMode(true)} />
) : (
<>
<FontAwesomeIcon icon={faCheck} className="text-success cursor-pointer" onClick={handleSave} />
<FontAwesomeIcon icon={faXmark} className="text-danger cursor-pointer" onClick={handleCancel} />
</>
)
)}
</div>
</div>
<div className="card-body d-flex flex-column gap-2">
<div>
<span className="text-muted small">Usuario</span>
{editMode ? (
<input className="form-control form-control-sm themed-input" value={formData.username} onChange={e => handleChange("username", e.target.value)} />
) : <div className="fw-semibold">{identity.username}</div>}
</div>
<div>
<span className="text-muted small">Email</span>
{editMode ? (
<input className="form-control form-control-sm themed-input" value={formData.email} onChange={e => handleChange("email", e.target.value)} />
) : <div>{identity.email}</div>}
</div>
<div className="mt-auto d-flex justify-content-between align-items-center">
{editMode ? (
<select className="form-select form-select-sm themed-input"
value={formData.status}
onChange={e => onRequestStatusChange?.(identity, parseInt(e.target.value))}
>
<option value={1}>Activa</option>
<option value={0}>Inactiva</option>
</select>
) : (
<span className={`badge ${identity.status === 1 ? "bg-success" : "bg-danger"}`}>
{identity.status === 1 ? "Activa" : "Inactiva"}
</span>
)}
<span className="text-muted small ms-2">
Creada el: {dayjs(identity.updatedAt).format("DD/MM/YYYY")}
</span>
</div>
</div>
</div>
);
};
AccountCard.propTypes = {
identity: PropTypes.object.isRequired,
onUpdate: PropTypes.func,
onRequestStatusChange: PropTypes.func,
confirmedStatusChange: PropTypes.object
};
export default AccountCard;

View File

@@ -0,0 +1,91 @@
import { useState, useRef, useEffect, cloneElement } from 'react';
import { Button } from 'react-bootstrap';
import { AnimatePresence, motion as _motion } from 'framer-motion';
import '@/css/AnimatedDropdown.css';
const AnimatedDropdown = ({
trigger,
icon,
variant = "secondary",
className = "",
buttonStyle = "",
show,
onToggle,
onMouseEnter,
onMouseLeave,
children
}) => {
const isControlled = show !== undefined;
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const dropdownRef = useRef(null);
const actualOpen = isControlled ? show : open;
const toggle = () => {
const newState = !actualOpen;
if (!isControlled) setOpen(newState);
onToggle?.(newState);
};
useEffect(() => {
const handleClickOutside = (e) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target) &&
!triggerRef.current?.contains(e.target)
) {
if (!isControlled) setOpen(false);
onToggle?.(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isControlled, onToggle]);
const triggerElement = trigger
? (typeof trigger === "function"
? trigger({ onClick: toggle, ref: triggerRef })
: cloneElement(trigger, { onClick: toggle, ref: triggerRef }))
: (
<Button
ref={triggerRef}
variant={variant}
className={`circle-btn ${buttonStyle}`}
onClick={toggle}
>
{icon}
</Button>
);
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
return (
<div
className={`position-relative d-inline-block`}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
ref={triggerRef}
>
{triggerElement}
<AnimatePresence>
{actualOpen && (
<_motion.div
ref={dropdownRef}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.15 }}
className={dropdownClasses}
>
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
</_motion.div>
)}
</AnimatePresence>
</div>
);
};
export default AnimatedDropdown;

View File

@@ -0,0 +1,122 @@
import { useState, useRef, useEffect, cloneElement } from 'react';
import { Button } from 'react-bootstrap';
import { AnimatePresence, motion as _motion } from 'framer-motion';
import '../css/AnimatedDropdown.css';
const AnimatedDropend = ({
trigger,
icon,
variant = "secondary",
className = "",
buttonStyle = "",
show,
onToggle,
onMouseEnter,
onMouseLeave,
children
}) => {
const isControlled = show !== undefined;
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const dropdownRef = useRef(null);
const actualOpen = isControlled ? show : open;
const toggle = (forceValue) => {
const newState = typeof forceValue === "boolean" ? forceValue : !actualOpen;
if (!isControlled) setOpen(newState);
onToggle?.(newState);
};
useEffect(() => {
const handleClickOutside = (e) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target) &&
!triggerRef.current?.contains(e.target)
) {
if (!isControlled) setOpen(false);
onToggle?.(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isControlled, onToggle]);
const handleMouseEnter = () => {
if (!isControlled) setOpen(true);
onToggle?.(true);
onMouseEnter?.();
};
const handleMouseLeave = () => {
if (!isControlled) setOpen(false);
onToggle?.(false);
onMouseLeave?.();
};
const triggerElement = trigger
? (typeof trigger === "function"
? trigger({
onClick: e => {
e.stopPropagation();
toggle();
},
ref: triggerRef
})
: cloneElement(trigger, {
onClick: e => {
e.stopPropagation();
toggle();
},
ref: triggerRef
}))
: (
<Button
ref={triggerRef}
variant={variant}
className={`circle-btn ${buttonStyle}`}
onClick={toggle}
>
{icon}
</Button>
);
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
return (
<div
className="position-relative d-inline-block dropend"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={triggerRef}
>
{triggerElement}
<AnimatePresence>
{actualOpen && (
<_motion.div
ref={dropdownRef}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.15 }}
className={dropdownClasses}
style={{
position: 'absolute',
top: '0',
left: '100%',
zIndex: 1000,
whiteSpace: 'nowrap'
}}
>
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
</_motion.div>
)}
</AnimatePresence>
</div>
);
};
export default AnimatedDropend;

View File

@@ -1,47 +1,28 @@
import { useConfig } from "../contexts/ConfigContext.jsx";
import Card from "./Card.jsx";
import { Route, Routes } from 'react-router-dom'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import Footer from "./Footer.jsx";
import Header from "./Header.jsx";
import ContentWrapper from "./ContentWrapper.jsx";
function App() {
const { config, loading, error } = useConfig();
if (loading) {
return (
<div className={"text-center py-5"}>
<FontAwesomeIcon icon={faSpinner} size={"6x"} spin={true} />
</div>
);
}
if (error) {
return (
<div className={"text-center py-5"}>
<h1>Error</h1>
<p>{error.message}</p>
</div>
);
}
import Header from "@/components/Header.jsx";
import Home from "@/pages/Home.jsx";
import Login from "@/pages/Login.jsx";
import Accounts from "@/pages/Accounts.jsx";
import ProtectedRoute from '@/components/Auth/ProtectedRoute';
import Register from '@/pages/Register.jsx';
const App = () => {
return (
<>
<Header />
<ContentWrapper>
<div className={"row g-4"}>
{config.map((card, index) => (
<Card
key={index}
{...card}
/>
))}
</div>
</ContentWrapper>
<Footer />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/accounts" element={
<ProtectedRoute>
<Accounts />
</ProtectedRoute>
} />
</Routes>
</>
);
}

View File

@@ -0,0 +1,8 @@
import { useAuth } from "../../hooks/useAuth.js";
const IfAuthenticated = ({ children }) => {
const { authStatus } = useAuth();
return authStatus === "authenticated" ? children : null;
};
export default IfAuthenticated;

View File

@@ -0,0 +1,8 @@
import { useAuth } from "../../hooks/useAuth.js";
const IfNotAuthenticated = ({ children }) => {
const { authStatus } = useAuth();
return authStatus === "unauthenticated" ? children : null;
};
export default IfNotAuthenticated;

View File

@@ -0,0 +1,13 @@
import { useAuth } from "../../hooks/useAuth.js";
const IfRole = ({ roles, children }) => {
const { identity, authStatus } = useAuth();
if (authStatus !== "authenticated") return null;
const userRole = identity?.metadata?.role;
return roles.includes(userRole) ? children : null;
};
export default IfRole;

View File

@@ -0,0 +1,121 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUser } from '@fortawesome/free-solid-svg-icons';
import { Form, Button, Alert, FloatingLabel} from 'react-bootstrap';
import PasswordInput from './PasswordInput.jsx';
import { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { AuthContext } from "@/context/AuthContext.jsx";
import CustomContainer from '@/components/CustomContainer.jsx';
import ContentWrapper from '@/components/ContentWrapper.jsx';
import { random } from '@/util/array.js';
import '@/css/LoginForm.css';
const LoginForm = () => {
const PHRASES = ["U got the wrong house fool!", "¿Te conozco?", "Hola :3", "¿Quién chota sos?🧐", "Identifícate", "Arto ahí ¿quién ere?"];
const { login, error } = useContext(AuthContext);
const [randomPhrase, setRandomPhrase] = useState("");
const navigate = useNavigate();
const [formState, setFormState] = useState({
username: "",
password: ""
});
useEffect(() => {
setRandomPhrase(random(PHRASES));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setFormState((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
const loginBody = {
username: formState.username,
password: formState.password,
serviceId: 0
};
try {
await login(loginBody);
navigate("/");
} catch (err) {
console.error("Error de login:", err.message);
}
};
return (
<CustomContainer>
<ContentWrapper>
<div className="login-card card p-5 mx-auto col-12 col-md-8 col-lg-6 col-xl-5 d-flex flex-column gap-4">
<h1 className="text-center">{randomPhrase}</h1>
<Form className="d-flex flex-column gap-5" onSubmit={handleSubmit}>
<div className="d-flex flex-column gap-3">
<FloatingLabel
controlId="floatingUsuario"
label={
<>
<FontAwesomeIcon icon={faUser} className="me-2" />
Usuario
</>
}
>
<Form.Control
type="text"
placeholder=""
name="username"
value={formState.username}
onChange={handleChange}
className="themed-input rounded-0"
/>
</FloatingLabel>
<PasswordInput
value={formState.password}
onChange={handleChange}
name="password"
/>
{/*<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center gap-2">
<Form.Check
type="checkbox"
name="keepLoggedIn"
label="Mantener sesión iniciada"
className="text-secondary"
value={formState.keepLoggedIn}
onChange={(e) => { formState.keepLoggedIn = e.target.checked; setFormState({ ...formState }) }}
/>
<Link disabled to="#" className="muted">
Olvidé mi contraseña
</Link>
</div>*/}
</div>
{error && (
<Alert variant="danger" className="text-center py-2 mb-0">
{error}
</Alert>
)}
<div className="text-center">
<Button type="submit" className="w-75 padding-4 rounded-0 border-0 shadow-sm login-button">
Iniciar sesión
</Button>
</div>
</Form>
</div>
</ContentWrapper>
</CustomContainer>
);
};
export default LoginForm;

View File

@@ -0,0 +1,56 @@
import { useState } from 'react';
import { Form, FloatingLabel, Button } from 'react-bootstrap';
import '@/css/PasswordInput.css';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEye, faEyeSlash, faKey } from '@fortawesome/free-solid-svg-icons';
import PropTypes from "prop-types";
const PasswordInput = ({ value, onChange, name = "password" }) => {
const [show, setShow] = useState(false);
const toggleShow = () => setShow(prev => !prev);
return (
<div className="position-relative w-100">
<FloatingLabel
controlId="passwordInput"
label={
<>
<FontAwesomeIcon icon={faKey} className="me-2" />
Contraseña
</>
}
>
<Form.Control
type={show ? "text" : "password"}
name={name}
value={value}
placeholder=""
onChange={onChange}
className="themed-input rounded-0 pe-5"
/>
</FloatingLabel>
<Button
variant="link"
className="show-button position-absolute end-0 top-50 translate-middle-y me-2"
onClick={toggleShow}
aria-label="Mostrar contraseña"
tabIndex={-1}
style={{ zIndex: 2 }}
>
<FontAwesomeIcon icon={show ? faEyeSlash : faEye} className='fa-lg' />
</Button>
</div>
);
};
PasswordInput.propTypes = {
value: PropTypes.any,
onChange: PropTypes.func,
name: PropTypes.string
}
export default PasswordInput;

View File

@@ -0,0 +1,18 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "../../hooks/useAuth.js";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
const ProtectedRoute = ({ minimumRoles, children }) => {
const { authStatus } = useAuth();
if (authStatus === "checking") return <FontAwesomeIcon icon={faSpinner} />;
if (authStatus === "unauthenticated") return <Navigate to="/login" replace />;
if (authStatus === "authenticated" && minimumRoles) {
const userRole = JSON.parse(localStorage.getItem("identity"))?.metadata?.role;
if (!minimumRoles.includes(userRole)) return <Navigate to="/" replace />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,113 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUser } from '@fortawesome/free-solid-svg-icons';
import { Form, Button, Alert, FloatingLabel} from 'react-bootstrap';
import PasswordInput from './PasswordInput.jsx';
import { useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
import { AuthContext } from "@/context/AuthContext.jsx";
import CustomContainer from '@/components/CustomContainer.jsx';
import ContentWrapper from '@/components/ContentWrapper.jsx';
import '@/css/LoginForm.css';
const RegisterForm = () => {
const { register, error } = useContext(AuthContext);
const navigate = useNavigate();
const [formState, setFormState] = useState({
username: "",
password: ""
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormState((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
const registerBody = {
username: formState.username,
password: formState.password,
serviceId: 0
};
try {
await register(registerBody);
navigate("/");
} catch (err) {
console.error("Error de registro:", err.message);
}
};
return (
<CustomContainer>
<ContentWrapper>
<div className="login-card card p-5 mx-auto col-12 col-md-8 col-lg-6 col-xl-5 d-flex flex-column gap-4">
<h1 className="text-center">Centro de cuentas</h1>
<Form className="d-flex flex-column gap-5" onSubmit={handleSubmit}>
<div className="d-flex flex-column gap-3">
<FloatingLabel
controlId="floatingUsuario"
label={
<>
<FontAwesomeIcon icon={faUser} className="me-2" />
Usuario
</>
}
>
<Form.Control
type="text"
placeholder=""
name="username"
value={formState.username}
onChange={handleChange}
className="themed-input rounded-0"
/>
</FloatingLabel>
<PasswordInput
value={formState.password}
onChange={handleChange}
name="password"
/>
{/*<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center gap-2">
<Form.Check
type="checkbox"
name="keepLoggedIn"
label="Mantener sesión iniciada"
className="text-secondary"
value={formState.keepLoggedIn}
onChange={(e) => { formState.keepLoggedIn = e.target.checked; setFormState({ ...formState }) }}
/>
<Link disabled to="#" className="muted">
Olvidé mi contraseña
</Link>
</div>*/}
</div>
{error && (
<Alert variant="danger" className="text-center py-2 mb-0">
{error}
</Alert>
)}
<div className="text-center">
<Button type="submit" className="w-75 padding-4 rounded-0 border-0 shadow-sm login-button">
Registrarse
</Button>
</div>
<span className="text-center">*Desde aquí podrás manejar todas tus cuentas</span>
</Form>
</div>
</ContentWrapper>
</CustomContainer>
);
};
export default RegisterForm;

View File

@@ -2,14 +2,15 @@ import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import PropTypes from "prop-types";
import { useState, useEffect } from "react";
import "@/css/Card.css"
export default function Card({ title, link }) {
const Card = ({ title, link }) => {
const [image, setImage] = useState("");
const [buttonContent, setButtonContent] = useState(<>{title}</>);
useEffect(() => {
const getImage = async () => {
const response = await fetch("https://api.miarma.net/v1/screenshot?url=" + link);
const response = await fetch("https://api.miarma.net/v1/screenshoter?url=" + link);
const blob = await response.blob();
const imageURL = URL.createObjectURL(blob);
setImage(imageURL);
@@ -52,4 +53,6 @@ export default function Card({ title, link }) {
Card.propTypes = {
title: PropTypes.string.isRequired,
link: PropTypes.string.isRequired
}
}
export default Card;

View File

@@ -0,0 +1,15 @@
import PropTypes from 'prop-types';
const CustomContainer = ({ children }) => {
return (
<main className="px-4 py-5">
{children}
</main>
);
}
CustomContainer.propTypes = {
children: PropTypes.node.isRequired,
}
export default CustomContainer;

View File

@@ -0,0 +1,26 @@
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Modal, Button } from "react-bootstrap";
const CustomModal = ({ show, onClose, title, children }) => {
return (
<Modal show={show} onHide={onClose} size="md" centered>
<Modal.Header className='justify-content-between rounded-top-4'>
<Modal.Title>{title}</Modal.Title>
<Button variant='transparent' onClick={onClose}>
<FontAwesomeIcon icon={faXmark} className='close-button fa-xl' />
</Button>
</Modal.Header>
<Modal.Body className="rounded-bottom-4 p-0"
style={{
maxHeight: '80vh',
overflowY: 'auto',
padding: '1rem',
}}>
{children}
</Modal.Body>
</Modal>
);
}
export default CustomModal;

View File

@@ -1,4 +1,4 @@
import License from './License';
import License from '@/components/License';
const Footer = () => {
return (

View File

@@ -1,8 +1,88 @@
import { Button } from "react-bootstrap";
import { Link, useLocation, useNavigate } from "react-router-dom";
import IfNotAuthenticated from "./Auth/IfNotAuthenticated";
import IfAuthenticated from "./Auth/IfAuthenticated";
import Avatar from "boring-avatars";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSignIn, faSignOut, faUserPlus } from "@fortawesome/free-solid-svg-icons";
import { useAuth } from "@/hooks/useAuth";
import AnimatedDropdown from "./AnimatedDropdown";
const Header = () => {
const location = useLocation();
const navigate = useNavigate();
const { identity, logout } = useAuth();
return (
<div className="d-flex justify-content-center my-3">
<img src="/images/logo-with-text.svg" width={192} height={192} />
</div>
<header className="position-relative my-3">
<div className="d-flex justify-content-center">
<Link to={"/"}>
<img src="/images/logo-with-text.svg" width={192} height={192} />
</Link>
</div>
<div className="position-absolute top-0 end-0 me-3 d-flex align-items-center gap-2">
<IfAuthenticated>
<AnimatedDropdown
trigger={
<div className="d-flex align-items-center gap-2 p-1 cursor-pointer">
<Avatar name={identity?.user.displayName} size={32} />
<span
className="fw-bold text-truncate d-none d-md-inline"
style={{ maxWidth: "120px" }}
>
@{identity?.account.username}
</span>
</div>
}
className="end-0"
>
{({ closeDropdown }) => (
<>
<Link to="/accounts" className="dropdown-item">
Mi cuenta
</Link>
<div
className="dropdown-item text-danger cursor-pointer"
onClick={() => {
closeDropdown();
logout();
navigate("/");
}}
>
<FontAwesomeIcon icon={faSignOut} className="me-2" />
Cerrar sesión
</div>
</>
)}
</AnimatedDropdown>
</IfAuthenticated>
<IfNotAuthenticated>
{!location.pathname.includes("login") && (
<Link to="/login">
<Button variant="primary" size="sm" className="rounded-0 px-3 py-1 d-flex align-items-center">
<FontAwesomeIcon icon={faSignIn} className="me-2" />
<span className="d-none d-md-inline">Iniciar sesión</span>
</Button>
</Link>
)}
{!location.pathname.includes("register") && (
<Link to="/register">
<Button variant="primary" size="sm" className="rounded-0 px-3 py-1 d-flex align-items-center">
<FontAwesomeIcon icon={faUserPlus} className="me-2" />
<span className="d-none d-md-inline">Registrarse</span>
</Button>
</Link>
)}
</IfNotAuthenticated>
</div>
</header>
);
}

15
src/components/List.jsx Normal file
View File

@@ -0,0 +1,15 @@
import ListItem from "./ListItem";
import {ListGroup} from 'react-bootstrap';
import '../css/List.css';
const List = ({ datos, config }) => {
return (
<ListGroup className="gap-2">
{datos.map((item, index) => (
<ListItem key={index} item={item} config={config} index={index} />
))}
</ListGroup>
);
};
export default List;

View File

@@ -0,0 +1,57 @@
import { motion as _motion } from "framer-motion";
import { ListGroup } from "react-bootstrap";
import '../css/ListItem.css';
const MotionListGroupItem = _motion.create(ListGroup.Item);
const ListItem = ({ item, config, index }) => {
const {
title,
subtitle,
numericField,
pfp,
showIndex,
} = config;
return (
<MotionListGroupItem
className="custom-list-item d-flex justify-content-between rounded-4 align-items-center"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="d-flex align-items-center gap-3">
{showIndex && (
<div className="list-item-index">
{index + 1}
</div>
)}
{pfp && item[pfp] && (
<img
src={item[pfp]}
alt="pfp"
className="list-item-avatar"
/>
)}
<div className="d-flex flex-column">
{title && item[title] && (
<h5 className="fw-bold m-0">{item[title]}</h5>
)}
{subtitle && item[subtitle] && (
<div className="subtitle m-0">{item[subtitle]}</div>
)}
</div>
</div>
{numericField && item[numericField] !== undefined && (
<span className="badge bg-primary rounded-pill">
{item[numericField]}
</span>
)}
</MotionListGroupItem>
);
};
export default ListItem;

View File

@@ -0,0 +1,10 @@
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
const LoadingIcon = () => {
return (
<FontAwesomeIcon icon={faSpinner} className='fa-spin fa-lg' />
);
}
export default LoadingIcon;

View File

@@ -0,0 +1,69 @@
import PropTypes from 'prop-types';
import { Modal, Button } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCircleCheck,
faCircleXmark,
faCircleExclamation,
faCircleInfo
} from '@fortawesome/free-solid-svg-icons';
const iconMap = {
success: faCircleCheck,
danger: faCircleXmark,
warning: faCircleExclamation,
info: faCircleInfo
};
const NotificationModal = ({
show,
onClose,
title,
message,
variant = "info",
buttons = [{ label: "Aceptar", variant: "primary", onClick: onClose }]
}) => {
return (
<Modal show={show} onHide={onClose} centered>
<Modal.Header closeButton className={`bg-${variant} ${variant === 'info' ? 'text-dark' : 'text-white'}`}>
<Modal.Title>
<FontAwesomeIcon icon={iconMap[variant] || faCircleInfo} className="me-2" />
{title}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="mb-0">{message}</p>
</Modal.Body>
<Modal.Footer>
{buttons.map((btn, index) => (
<Button
key={index}
variant={btn.variant || "primary"}
onClick={btn.onClick || onClose}
>
{btn.label}
</Button>
))}
</Modal.Footer>
</Modal>
);
};
NotificationModal.propTypes = {
show: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
variant: PropTypes.oneOf(['success', 'danger', 'warning', 'info']),
buttons: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
variant: PropTypes.string,
onClick: PropTypes.func
})
)
};
export default NotificationModal;

View File

@@ -0,0 +1,32 @@
import PropTypes from 'prop-types';
import LoadingIcon from '@/components/LoadingIcon.jsx';
import "@/css/PaginatedCardGrid.css"
const PaginatedCardGrid = ({
items = [],
renderCard,
loaderRef,
loading = false
}) => {
return (
<div className="cards-grid">
{items.map((item, i) => renderCard(item, i))}
<div ref={loaderRef} className="loading-trigger d-flex justify-content-center align-items-center">
{loading && <LoadingIcon />}
</div>
</div>
);
};
PaginatedCardGrid.propTypes = {
items: PropTypes.array,
renderCard: PropTypes.func.isRequired,
creatingItem: PropTypes.any,
renderCreatingCard: PropTypes.func,
loaderRef: PropTypes.object,
loading: PropTypes.bool
};
export default PaginatedCardGrid;

View File

@@ -0,0 +1,18 @@
import { useTheme } from "../hooks/useTheme.js";
import "../css/ThemeButton.css";
export default function ThemeButton({ className, onlyIcon}) {
const { theme, toggleTheme } = useTheme();
return (
<button className={`theme-toggle ${className}`} onClick={toggleTheme}>
{
onlyIcon ? (
theme === "dark" ? ("🌞") : ("🌙")
) : (
theme === "dark" ? ("🌞 tema claro") : ("🌙 tema oscuro")
)
}
</button>
);
}

168
src/context/AuthContext.jsx Normal file
View File

@@ -0,0 +1,168 @@
import { useState, useEffect, createContext } from "react";
import createAxiosInstance from "@/api/axiosInstance";
import { useConfig } from "@/hooks/useConfig";
export const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const axios = createAxiosInstance();
const { config } = useConfig();
const [token, setToken] = useState(() => localStorage.getItem("token"));
const [identity, setIdentity] = useState(() => {
const stored = localStorage.getItem("identity");
return stored ? JSON.parse(stored) : null;
});
const [authStatus, setAuthStatus] = useState("checking");
const [error, setError] = useState(null);
useEffect(() => {
if (!config) return;
if (!token) {
setAuthStatus("unauthenticated");
return;
}
const BASE_URL = config.apiConfig.coreUrl;
const VALIDATE_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.validateToken}`;
const checkAuth = async () => {
try {
const res = await axios.get(VALIDATE_URL, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 200) {
setAuthStatus("authenticated");
} else {
logout();
}
} catch (err) {
console.error("Error validando token:", err);
logout();
}
};
checkAuth();
}, [token, config]);
const login = async (formData) => {
setError(null);
const BASE_URL = config.apiConfig.baseUrl;
const LOGIN_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.login}`;
try {
const res = await axios.post(LOGIN_URL, formData);
const { token, user, account, metadata } = res.data;
const identity = {
user,
account,
metadata,
};
localStorage.setItem("token", token);
localStorage.setItem("identity", JSON.stringify(identity));
setToken(token);
setIdentity(identity);
setAuthStatus("authenticated");
} catch (err) {
console.error("Error al iniciar sesión:", err);
let message = "Ha ocurrido un error inesperado.";
if (err.response) {
const { status, data } = err.response;
if (status === 400) {
message = "Usuario o contraseña incorrectos.";
} else if (status === 403) {
message = "Tu cuenta está inactiva o suspendida.";
} else if (status === 404) {
message = "Usuario no encontrado.";
} else if (data?.message) {
message = data.message;
}
}
setError(message);
throw new Error(message);
}
};
const register = async (formData) => {
setError(null);
const BASE_URL = config.apiConfig.baseUrl;
const REGISTER_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.register}`;
try {
const res = await axios.post(REGISTER_URL, formData);
const { token, user, account, metadata } = res.data;
const identity = {
user,
account,
metadata,
};
localStorage.setItem("token", token);
localStorage.setItem("identity", JSON.stringify(identity));
setToken(token);
setIdentity(identity);
setAuthStatus("authenticated");
} catch (err) {
console.error("Error al registrarse:", err);
let message = "Ha ocurrido un error inesperado.";
if (err.response) {
const { status, data } = err.response;
if (status === 400) {
message = "Usuario o contraseña incorrectos.";
} else if (status === 403) {
message = "Tu cuenta está inactiva o suspendida.";
} else if (status === 404) {
message = "Usuario no encontrado.";
} else if (data?.message) {
message = data.message;
}
}
setError(message);
throw new Error(message);
}
};
const logout = () => {
localStorage.removeItem("token");
localStorage.removeItem("identity");
setIdentity(null);
setToken(null);
setAuthStatus("unauthenticated");
};
return (
<AuthContext.Provider
value={{
identity, // { user, account, metadata }
token,
authStatus,
login,
register,
logout,
error,
}}
>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,41 @@
import { createContext, useState, useEffect } from "react";
import PropTypes from "prop-types";
const ConfigContext = createContext();
export const ConfigProvider = ({ children }) => {
const [config, setConfig] = useState(null);
const [configLoading, setLoading] = useState(true);
const [configError, setError] = useState(null);
useEffect(() => {
const fetchConfig = async () => {
try {
const response = import.meta.env.MODE === 'production'
? await fetch("/config/settings.prod.json")
: await fetch("/config/settings.dev.json");
if (!response.ok) throw new Error("Error al cargar settings.*.json");
const json = await response.json();
setConfig(json);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchConfig();
}, []);
return (
<ConfigContext.Provider value={{ config, configLoading, configError }}>
{children}
</ConfigContext.Provider>
);
};
ConfigProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export {ConfigContext};

View File

@@ -0,0 +1,23 @@
import { createContext } from "react";
import PropTypes from "prop-types";
import { useData } from "../hooks/useData";
export const DataContext = createContext();
export const DataProvider = ({ config, onError, children }) => {
const data = useData(config, onError);
return (
<DataContext.Provider value={data}>
{children}
</DataContext.Provider>
);
};
DataProvider.propTypes = {
config: PropTypes.shape({
baseUrl: PropTypes.string.isRequired,
params: PropTypes.object,
}).isRequired,
children: PropTypes.node.isRequired,
};

View File

@@ -0,0 +1,40 @@
import { createContext, useState, useContext } from 'react';
import PropTypes from 'prop-types';
import NotificationModal from '@/components/NotificationModal';
const ErrorContext = createContext();
export const ErrorProvider = ({ children }) => {
const [error, setError] = useState(null);
const showError = (err) => {
setError({
title: err.status ? `Error ${err.status}` : "Error",
message: err.message,
variant: 'danger'
});
};
const closeError = () => setError(null);
return (
<ErrorContext.Provider value={{ showError }}>
{children}
{error && (
<NotificationModal
show={true}
onClose={closeError}
title={error.title}
message={error.message}
variant='danger'
buttons={[{ label: "Aceptar", variant: "danger", onClick: closeError }]}
/>
)}
</ErrorContext.Provider>
);
};
ErrorProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export const useError = () => useContext(ErrorContext);

View File

@@ -0,0 +1,31 @@
import { createContext, useEffect, useState } from "react";
export const ThemeContext = createContext();
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(() => {
return (
localStorage.getItem("theme") ||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
);
});
useEffect(() => {
const root = document.documentElement;
document.body.classList.remove("light", "dark");
document.body.classList.add(theme);
root.classList.remove("light", "dark");
root.classList.add(theme);
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};

View File

@@ -1,60 +0,0 @@
import { createContext, useContext, useState, useEffect } from "react";
import PropTypes from "prop-types";
/**
* ConfigContext.jsx
*
* Este archivo define el contexto de configuración para la aplicación, permitiendo cargar y manejar la configuración desde un archivo externo.
*
* Importaciones:
* - createContext, useContext, useState, useEffect: Funciones de React para crear y utilizar contextos, manejar estados y efectos secundarios.
* - PropTypes: Librería para la validación de tipos de propiedades en componentes de React.
*
* Funcionalidad:
* - ConfigContext: Contexto que almacena la configuración cargada, el estado de carga y cualquier error ocurrido durante la carga de la configuración.
* - ConfigProvider: Proveedor de contexto que maneja la carga de la configuración y proporciona el estado de la configuración a los componentes hijos.
* - Utiliza `fetch` para cargar la configuración desde un archivo JSON.
* - Maneja el estado de carga y errores durante la carga de la configuración.
* - useConfig: Hook personalizado para acceder al contexto de configuración.
*
* PropTypes:
* - ConfigProvider espera un único hijo (`children`) que es requerido y debe ser un nodo de React.
*
*/
const ConfigContext = createContext();
export const ConfigProvider = ({ children }) => {
const [config, setConfig] = useState([]);
const [configLoading, setLoading] = useState(true);
const [configError, setError] = useState(null);
useEffect(() => {
const fetchConfig = async () => {
try {
const response = await fetch("/config/settings.json");
if (!response.ok) throw new Error("Error al cargar settings.json");
const json = await response.json();
setConfig(json);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchConfig();
}, []);
return (
<ConfigContext.Provider value={{ config, configLoading, configError }}>
{children}
</ConfigContext.Provider>
);
};
ConfigProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export const useConfig = () => useContext(ConfigContext);

View File

@@ -0,0 +1,28 @@
.dropdown-menu .dropdown-divider {
border-top: 1px solid var(--divider-color);
}
.dropdown-menu {
background-color: var(--bg-color) !important;
color: var(--text-color) !important;
}
.dropdown-menu.show {
background-color: var(--navbar-bg) !important;
box-shadow: 0 5px 10px var(--shadow-color);
}
.dropdown-item {
background-color: var(--navbar-bg) !important;
color: var(--navbar-dropdown-item-color);
cursor: pointer;
}
.dropdown-item:hover {
background-color: var(--navbar-bg) !important;
color: var(--secondary-color) !important;
}
.disabled.text-muted {
color: var(--muted-color) !important;
}

32
src/css/LoginForm.css Normal file
View File

@@ -0,0 +1,32 @@
/* ================================
LOGIN - CARD CONTAINER (VISUAL)
================================== */
.login-card {
background-color: var(--login-bg) !important;
color: var(--text-color);
}
/* ================================
INPUTS VISUALES
================================== */
input.form-control {
background-color: var(--input-bg);
color: var(--input-text);
border: 1px solid var(--input-border);
}
/* ================================
LABELS PERSONALIZADAS
================================== */
.form-floating>label {
font-family: 'Product Sans';
font-size: 1.1em;
color: var(--label-color);
}
.form-floating>label::after {
background-color: transparent !important;
}

View File

@@ -0,0 +1,16 @@
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 1.5rem;
}
@media (max-width: 768px) {
.cards-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
}
.loading-trigger {
margin-top: 2rem;
text-align: center;
}

View File

@@ -0,0 +1,7 @@
button.show-button {
color: var(--show-btn-color);
}
button.show-button:hover {
color: var(--show-btn-hover);
}

View File

@@ -27,6 +27,7 @@
}
:root {
/* Colores base */
--light: #f6f6f6;
--white: #ffffff;
--blue: #2F6CA3;
@@ -37,6 +38,31 @@
--text-dark: #212529;
--text-light: #ffffff;
--bg: #efefef;
/* Login / Inputs */
--login-bg: var(--white);
--text-color: var(--text-dark);
--input-bg: var(--light);
--input-text: var(--text-dark);
--input-border: var(--muted);
--label-color: var(--muted);
--placeholder-color: var(--muted);
--input-focus-shadow: rgba(47, 108, 163, 0.25);
/* Botones */
--show-btn-color: var(--blue);
--show-btn-hover: var(--blue-dark);
--accent-color: var(--blue);
/* Dropdown / Navbar */
--navbar-bg: var(--white);
--navbar-dropdown-item-color: var(--text-dark);
--divider-color: #dee2e6;
--shadow-color: rgba(0, 0, 0, 0.15);
/* Estados / Extra */
--secondary-color: var(--blue-dark);
--muted-color: var(--muted);
}
html,
@@ -86,4 +112,59 @@ footer {
margin-top: 30px;
text-align: center;
color: var(--muted);
}
.modal-body {
background-color: transparent;
}
.modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: none;
background-color: var(--modal-bg);
}
.themed-input {
background-color: var(--input-bg) !important;
color: var(--input-text) !important;
border: 1px solid var(--input-border) !important;
border-radius: 8px;
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
}
.themed-input:focus {
border-color: var(--accent-color) !important;
box-shadow: 0 0 0 0.25rem var(--input-focus-shadow);
outline: none;
}
.themed-input::placeholder {
color: var(--placeholder-color) !important;
}
.hidden {
display: none;
}
.logout {
color: rgb(215, 48, 48);
transition: all 0.2s;
}
.logout:hover {
color: rgb(162, 26, 26);
transition: all 0.2s;
}
.cursor-pointer {
cursor: pointer;
}
.muted {
color: #868686;
}

4
src/hooks/useAuth.js Normal file
View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { AuthContext } from "../context/AuthContext";
export const useAuth = () => useContext(AuthContext);

4
src/hooks/useConfig.js Normal file
View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { ConfigContext } from "../context/ConfigContext.jsx";
export const useConfig = () => useContext(ConfigContext);

140
src/hooks/useData.js Normal file
View File

@@ -0,0 +1,140 @@
import { useState, useEffect, useCallback, useRef } from "react";
import axios from "axios";
export const useData = (config, onError) => {
const [data, setData] = useState(null);
const [dataLoading, setLoading] = useState(true);
const [dataError, setError] = useState(null);
const configRef = useRef();
useEffect(() => {
if (config?.baseUrl) {
configRef.current = config;
}
}, [config]);
const getAuthHeaders = (isFormData = false) => {
const token = localStorage.getItem("token");
const headers = {};
if (token) headers.Authorization = `Bearer ${token}`;
if (!isFormData) {
headers["Content-Type"] = "application/json";
}
return headers;
};
const handleAxiosError = (err) => {
if (err.response && err.response.data) {
const data = err.response.data;
if (data.status === 422 && data.errors) {
return {
status: 422,
errors: data.errors,
path: data.path ?? null,
timestamp: data.timestamp ?? null,
};
}
return {
status: data.status ?? err.response.status,
error: data.error ?? null,
message: data.message ?? err.response.statusText ?? "Error desconocido",
path: data.path ?? null,
timestamp: data.timestamp ?? null,
};
}
if (err.request) {
return {
status: null,
error: "Network Error",
message: "No se pudo conectar al servidor",
path: null,
timestamp: new Date().toISOString(),
};
}
return {
status: null,
error: "Client Error",
message: err.message || "Error desconocido",
path: null,
timestamp: new Date().toISOString(),
};
};
const fetchData = useCallback(async () => {
const current = configRef.current;
if (!current?.baseUrl) return;
setLoading(true);
setError(null);
try {
const response = await axios.get(current.baseUrl, {
headers: getAuthHeaders(),
params: current.params,
});
setData(response.data);
} catch (err) {
const error = handleAxiosError(err);
setError(error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (config?.baseUrl) fetchData();
}, [config, fetchData]);
const requestWrapper = async (method, endpoint, payload = null, refresh = false) => {
try {
const isFormData = payload instanceof FormData;
const headers = getAuthHeaders(isFormData);
const cfg = { headers };
let response;
if (method === "get") {
if (payload) cfg.params = payload;
response = await axios.get(endpoint, cfg);
} else if (method === "delete") {
if (payload) cfg.data = payload;
response = await axios.delete(endpoint, cfg);
} else {
response = await axios[method](endpoint, payload, cfg);
}
if (refresh) await fetchData();
return response.data;
} catch (err) {
const error = handleAxiosError(err);
if (error.status !== 422 && onError) {
onError(error);
}
setError(error);
throw error;
}
};
const clearError = () => setError(null);
return {
data,
dataLoading,
dataError,
clearError,
getData: (url, params, refresh = true) => requestWrapper("get", url, params, refresh),
postData: (url, body, refresh = true) => requestWrapper("post", url, body, refresh),
putData: (url, body, refresh = true) => requestWrapper("put", url, body, refresh),
deleteData: (url, refresh = true) => requestWrapper("delete", url, null, refresh),
deleteDataWithBody: (url, body, refresh = true) => requestWrapper("delete", url, body, refresh)
};
};

View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { DataContext } from "../context/DataContext";
export const useDataContext = () => useContext(DataContext);

View File

@@ -0,0 +1,35 @@
import { useEffect, useState } from 'react';
import axios from 'axios';
import { useConfig } from './useConfig';
const useRequestCount = () => {
const { config } = useConfig();
const [count, setCount] = useState(null);
useEffect(() => {
if (!config) return;
const fetchCount = async () => {
try {
const res = await axios.get(
config.apiConfig.baseUrl + config.apiConfig.endpoints.requests.count,
{
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
}
);
setCount(res.data.count);
} catch (err) {
console.error('❌ Error al obtener el número de solicitudes:', err.message);
}
};
fetchCount();
}, [config]);
return count;
};
export default useRequestCount;

View File

@@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { parseJwt } from "../util/tokenUtils.js";
import NotificationModal from "../components/NotificationModal.jsx";
import axios from "axios";
import { useAuth } from "./useAuth.js";
import { useConfig } from "./useConfig.js";
const useSessionRenewal = () => {
const { logout } = useAuth();
const { config } = useConfig();
const [showModal, setShowModal] = useState(false);
const [alreadyWarned, setAlreadyWarned] = useState(false);
useEffect(() => {
const interval = setInterval(() => {
const token = localStorage.getItem("token");
const decoded = parseJwt(token);
if (!token || !decoded?.exp) return;
const now = Date.now();
const expTime = decoded.exp * 1000;
const timeLeft = expTime - now;
if (timeLeft <= 60000 && timeLeft > 0 && !alreadyWarned) {
setShowModal(true);
setAlreadyWarned(true);
}
if (timeLeft <= 0) {
clearInterval(interval);
logout();
}
}, 10000);
return () => clearInterval(interval);
}, [alreadyWarned, logout]);
const handleRenew = async () => {
const token = localStorage.getItem("token");
const decoded = parseJwt(token);
const now = Date.now();
const expTime = decoded?.exp * 1000;
if (!token || !decoded || now > expTime) {
logout();
return;
}
try {
const response = await axios.get(
`${config.apiConfig.coreUrl}${config.apiConfig.endpoints.auth.refreshToken}`,
null,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const newToken = response.data.token;
localStorage.setItem("token", newToken);
setShowModal(false);
setAlreadyWarned(false);
} catch (err) {
console.error("Error renovando sesión:", err);
logout();
}
};
const modal = showModal && (
<NotificationModal
show={true}
onClose={() => {
setShowModal(false);
logout();
}}
title="¿Quieres seguir conectado?"
message="Tu sesión está a punto de expirar. ¿Quieres renovarla 1 hora más?"
variant="info"
buttons={[
{
label: "Renovar sesión",
variant: "success",
onClick: handleRenew,
},
{
label: "Cerrar sesión",
variant: "danger",
onClick: () => {
logout();
setShowModal(false);
},
},
]}
/>
);
return { modal };
};
export default useSessionRenewal;

10
src/hooks/useTheme.js Normal file
View File

@@ -0,0 +1,10 @@
import { useContext } from "react";
import { ThemeContext } from "../context/ThemeContext";
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme debe usarse dentro de un <ThemeProvider>");
}
return context;
};

View File

@@ -1,14 +1,25 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './components/App.jsx'
import { ConfigProvider } from './contexts/ConfigContext.jsx'
import './css/index.css';
import './css/Card.css';
import App from '@/components/App.jsx'
import { ConfigProvider } from '@/context/ConfigContext.jsx'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider } from '@/context/ThemeContext'
import { AuthProvider } from '@/context/AuthContext'
import { ErrorProvider } from '@/context/ErrorContext.jsx'
import '@/css/index.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<ConfigProvider>
<App />
<ThemeProvider>
<ErrorProvider>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</ErrorProvider>
</ThemeProvider>
</ConfigProvider>
</StrictMode>,
)

136
src/pages/Accounts.jsx Normal file
View File

@@ -0,0 +1,136 @@
// Accounts.jsx
import { DataProvider } from "@/context/DataContext";
import { useError } from "@/context/ErrorContext";
import LoadingIcon from "@/components/LoadingIcon";
import { useDataContext } from "@/hooks/useDataContext";
import CustomContainer from "@/components/CustomContainer";
import ContentWrapper from "@/components/ContentWrapper";
import { useConfig } from "@/hooks/useConfig";
import PropTypes from 'prop-types';
import PaginatedCardGrid from "@/components/PaginatedCardGrid";
import AccountCard from "@/components/Accounts/AccountCard";
import CustomModal from "@/components/CustomModal";
import { Button } from "react-bootstrap";
import { useState } from "react";
const Accounts = () => {
const { config, configLoading } = useConfig();
const { showError } = useError();
if (configLoading) return <p><LoadingIcon /></p>;
const identity = JSON.parse(localStorage.getItem("identity"));
const BASE_URL = `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.credentials.byUserId}`
.replace(":userId", identity?.user.userId);
const BY_ID_URL = `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.credentials.byId}`;
const reqConfig = {
baseUrl: BASE_URL,
byIdUrl: BY_ID_URL,
params: {}
};
return (
<DataProvider config={reqConfig} onError={showError}>
<AccountsContent reqConfig={reqConfig} />
</DataProvider>
);
}
const AccountsContent = ({ reqConfig }) => {
const { data, dataLoading, putData } = useDataContext();
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [pendingAccountId, setPendingAccountId] = useState(null);
const [pendingStatus, setPendingStatus] = useState(null);
const [confirmedStatusChange, setConfirmedStatusChange] = useState(null);
const handleRequestStatusChange = (identity, newStatus) => {
if (newStatus === 0 && identity?.status !== 0) {
setPendingAccountId(identity?.credentialId);
setPendingStatus(newStatus);
setShowConfirmModal(true);
}
};
const handleConfirmDeactivation = () => {
setConfirmedStatusChange({
credentialId: pendingAccountId,
status: pendingStatus
});
setShowConfirmModal(false);
setPendingAccountId(null);
setPendingStatus(null);
};
const handleUpdate = async (updatedIdentity) => {
try {
await putData(
reqConfig.byIdUrl.replace(":credentialId", updatedIdentity?.credentialId),
{
username: updatedIdentity?.username,
email: updatedIdentity?.email,
status: updatedIdentity?.status,
serviceId: updatedIdentity?.serviceId
},
true
);
} catch (err) {
console.error(err);
}
};
if (dataLoading) return <p><LoadingIcon /></p>;
return (
<CustomContainer>
<ContentWrapper>
<PaginatedCardGrid
items={data}
renderCard={(identity, idx) => (
<AccountCard
key={idx}
identity={identity}
onUpdate={handleUpdate}
onRequestStatusChange={handleRequestStatusChange}
confirmedStatusChange={confirmedStatusChange}
/>
)}
/>
</ContentWrapper>
{showConfirmModal && (
<CustomModal
show={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
title="Confirmar desactivación"
>
<div className="p-4">
<p>¿Seguro que quieres pasar esta cuenta a estado inactivo? Después de desactivarla sólo podra ser reactivada o editada otra vez por un administador.</p>
<div className="d-flex justify-content-end gap-2 mt-4">
<Button
variant="secondary"
onClick={() => setShowConfirmModal(false)}
>
Cancelar
</Button>
<Button
variant="danger"
onClick={handleConfirmDeactivation}
>
Confirmar
</Button>
</div>
</div>
</CustomModal>
)}
</CustomContainer>
);
}
AccountsContent.propTypes = {
reqConfig: PropTypes.object.isRequired
};
export default Accounts;

39
src/pages/Home.jsx Normal file
View File

@@ -0,0 +1,39 @@
import ContentWrapper from "@/components/ContentWrapper.jsx";
import Card from "@/components/Card";
import { useConfig } from "@/hooks/useConfig";
import LoadingIcon from "@/components/LoadingIcon";
import PropTypes from 'prop-types';
import CustomContainer from "@/components/CustomContainer";
const Home = () => {
const { config, configLoading } = useConfig();
if (configLoading) return <p className="text-center my-5"><LoadingIcon /></p>;
return (
<HomeContent cards={config.pages} />
);
}
const HomeContent = ({ cards }) => {
return (
<CustomContainer>
<ContentWrapper>
<div className={"row g-4"}>
{cards.map((card, index) => (
<Card
key={index}
{...card}
/>
))}
</div>
</ContentWrapper>
</CustomContainer>
);
}
HomeContent.propTypes = {
cards: PropTypes.array.isRequired
};
export default Home;

21
src/pages/Login.jsx Normal file
View File

@@ -0,0 +1,21 @@
import LoginForm from "@/components/Auth/LoginForm";
import { useAuth } from "@/hooks/useAuth";
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
const Login = () => {
const { authStatus } = useAuth();
const navigate = useNavigate();
useEffect(() => {
if (authStatus === "authenticated") {
navigate("/");
}
}, [authStatus, navigate]);
return (
<LoginForm />
);
}
export default Login;

17
src/pages/Register.jsx Normal file
View File

@@ -0,0 +1,17 @@
import RegisterForm from "@/components/Auth/RegisterForm";
import { useAuth } from "@/hooks/useAuth";
import { useNavigate } from "react-router-dom";
const Register = () => {
const { authStatus } = useAuth();
const navigate = useNavigate();
if (authStatus == "authenticated")
navigate("/");
return (
<RegisterForm />
);
}
export default Register;

5
src/util/array.js Normal file
View File

@@ -0,0 +1,5 @@
const random = (arr) => {
return arr[Math.floor(Math.random() * arr.length)]
}
export { random }

11
src/util/constants.js Normal file
View File

@@ -0,0 +1,11 @@
'use strict';
const CONSTANTS = {
CORE_ID: 0,
HUERTOS_ID: 1,
MINECRAFT_ID: 2,
CINE_ID: 3,
MPASTE_ID: 4
};
export { CONSTANTS };