Initial commit

This commit is contained in:
2026-02-15 02:53:57 +01:00
commit f3ba3cf1b1
66 changed files with 3016 additions and 0 deletions

28
src/App.jsx Normal file
View File

@@ -0,0 +1,28 @@
import Header from '@/components/Header.jsx';
import NavBar from '@/components/NavBar.jsx';
import Footer from '@/components/Footer.jsx';
import { Route, Routes, useLocation } from 'react-router-dom'
import ProtectedRoute from '@/components/Auth/ProtectedRoute.jsx'
import useSessionRenewal from '@/hooks/useSessionRenewal'
import { CONSTANTS } from '@/util/constants'
import Home from '@/pages/Home.jsx'
import Building from '@/pages/Building.jsx'
function App() {
const routesWithFooter = ["/"];
return (
<>
<Header />
<NavBar />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/*" element={<Building />} />
</Routes>
{routesWithFooter.includes(useLocation().pathname) ? <Footer /> : null}
</>
)
}
export default App

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,92 @@
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`}
onClick={toggle}
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

@@ -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 { user, authStatus } = useAuth();
if (authStatus !== "authenticated") return null;
const userRole = user?.role;
return roles.includes(userRole) ? children : null;
};
export default IfRole;

View File

@@ -0,0 +1,120 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUser } from '@fortawesome/free-solid-svg-icons';
import { Form, Button, Alert, FloatingLabel, Row, Col } from 'react-bootstrap';
import PasswordInput from '@/components/Auth/PasswordInput.jsx';
import { useContext, useState } from "react";
import { Link, 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 LoginForm = () => {
const { login, error } = useContext(AuthContext);
const navigate = useNavigate();
const [formState, setFormState] = useState({
emailOrUserName: "",
password: "",
keepLoggedIn: false
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormState((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formState.emailOrUserName);
const loginBody = {
password: formState.password,
keepLoggedIn: Boolean(formState.keepLoggedIn),
};
if (isEmail) {
loginBody.email = formState.emailOrUserName;
} else {
loginBody.userName = formState.emailOrUserName;
}
try {
await login(loginBody);
navigate("/");
} catch (err) {
console.error("Error de login:", err.message);
}
};
return (
<CustomContainer>
<ContentWrapper>
<div className="login-card card shadow p-5 rounded-5 mx-auto col-12 col-md-8 col-lg-6 col-xl-5 d-flex flex-column gap-4">
<h1 className="text-center">Inicio de sesión</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 o Email
</>
}
>
<Form.Control
type="text"
placeholder=""
name="emailOrUserName"
value={formState.emailOrUserName}
onChange={handleChange}
className="rounded-4"
/>
</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-4 border-0 shadow-sm login-button">
Iniciar sesión
</Button>
</div>
</Form>
</div>
</ContentWrapper>
</CustomContainer>
);
};
export default LoginForm;

View File

@@ -0,0 +1,48 @@
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';
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="rounded-4 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>
);
};
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} />; // o un loader si quieres
if (authStatus === "unauthenticated") return <Navigate to="/login" replace />;
if (authStatus === "authenticated" && minimumRoles) {
const userRole = JSON.parse(localStorage.getItem("user"))?.role;
if (!minimumRoles.includes(userRole)) return <Navigate to="/" replace />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,15 @@
import PropTypes from 'prop-types';
const ContentWrapper = ({ children }) => {
return (
<div className="container-xl">
{children}
</div>
);
}
ContentWrapper.propTypes = {
children: PropTypes.node.isRequired,
}
export default ContentWrapper;

View File

@@ -0,0 +1,47 @@
import Slider from 'react-slick';
import '@/css/CustomCarousel.css';
const CustomCarousel = ({ images }) => {
const settings = {
dots: false,
infinite: true,
speed: 500,
slidesToShow: 2,
slidesToScroll: 1,
arrows: false,
autoplay: true,
autoplaySpeed: 3000,
responsive: [
{
breakpoint: 768, // móviles
settings: {
slidesToShow: 1,
arrows: false,
autoplay: true,
autoplaySpeed: 3000,
dots: false,
infinite: true,
speed: 500
}
}
]
};
return (
<div className="my-4">
<Slider {...settings}>
{images.map((src, index) => (
<div key={index} className='carousel-img-wrapper'>
<img
src={src}
alt={`slide-${index}`}
className="carousel-img"
/>
</div>
))}
</Slider>
</div>
);
};
export default CustomCarousel;

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="xl" 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;

56
src/components/Footer.jsx Normal file
View File

@@ -0,0 +1,56 @@
import React, { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
import '@/css/Footer.css';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
const Footer = () => {
const [heart, setHeart] = useState('💜');
useEffect(() => {
const hearts = ["❤️", "💛", "🧡", "💚", "💙", "💜"];
const randomHeart = () => hearts[Math.floor(Math.random() * hearts.length)];
const interval = setInterval(() => {
setHeart(randomHeart());
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<footer className="footer d-flex flex-column align-items-center gap-5 pt-5 px-4">
<div className="footer-columns w-100" style={{ maxWidth: '900px' }}>
<div className="footer-column">
<h4 className="footer-title">Contenido del footer</h4>
<div className="contact-info p-4">
<a
href="https://github.com/Gallardo7761"
target="_blank"
className='text-break d-block'
rel="noopener noreferrer"
>
<FontAwesomeIcon icon={faGithub} className="fa-icon me-2 " />
Gallardo7761
</a>
<a href="mailto:jose@miarma.net" className="text-break d-block">
<FontAwesomeIcon icon={faEnvelope} className="fa-icon me-2" />
jose@miarma.net
</a>
</div>
</div>
</div>
<div className="footer-bottom w-100 py-5 text-center">
<h6 id="devd" className='m-0'>
Hecho con <span className="heart-anim">{heart}</span> por{' '}
<a href="https://gallardo.dev" target="_blank" rel="noopener noreferrer">
Gallardo7761
</a>
</h6>
</div>
</footer>
);
};
export default Footer;

19
src/components/Header.jsx Normal file
View File

@@ -0,0 +1,19 @@
import '@/css/Header.css';
import { Link } from 'react-router-dom';
const Header = () => {
return (
<header className={`text-center bg-img`}>
<div className="m-0 p-5 mask">
<div className="d-flex flex-column justify-content-center align-items-center h-100">
<Link to='/' className='text-decoration-none'>
<h1 className='header-title m-0 text-white shadowed'>Tu página web</h1>
</Link>
</div>
</div>
</header>
);
}
export default Header;

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;

133
src/components/NavBar.jsx Normal file
View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from "@/hooks/useAuth";
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faSignIn,
faUser,
faSignOut,
faHouse,
faList,
faBullhorn,
faFile
} from '@fortawesome/free-solid-svg-icons';
import '@/css/NavBar.css';
import ThemeButton from '@/components/ThemeButton.jsx';
import IfAuthenticated from '@/components/Auth/IfAuthenticated.jsx';
import IfNotAuthenticated from '@/components/Auth/IfNotAuthenticated.jsx';
import IfRole from '@/components/Auth/IfRole.jsx';
import { Navbar, Nav, Container } from 'react-bootstrap';
import AnimatedDropdown from '@/components/AnimatedDropdown.jsx';
import { CONSTANTS } from '@/util/constants.js';
const NavBar = () => {
const { user, logout } = useAuth();
const [showingUserDropdown, setShowingUserDropdown] = useState(false);
const [expanded, setExpanded] = useState(false);
const [isLg, setIsLg] = useState(window.innerWidth >= 992);
useEffect(() => {
const handleResize = () => {
setIsLg(window.innerWidth >= 992 && window.innerWidth < 1200);
};
handleResize(); // inicializar
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 992) {
setExpanded(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
return (
<Navbar expand="lg" sticky="top" expanded={expanded} onToggle={() => setExpanded(!expanded)}>
<Container fluid>
<Navbar.Toggle aria-controls="navbar" className="custom-toggler">
<svg width="30" height="30" viewBox="0 0 30 30">
<path
d="M4 7h22M4 15h22M4 23h22"
stroke="var(--navbar-link-color)"
strokeWidth="3"
strokeLinecap="round"
strokeMiterlimit="10"
/>
</svg>
</Navbar.Toggle>
<Navbar.Collapse id="main-navbar">
<Nav className="me-auto gap-2">
<Nav.Link
as={Link}
to="/"
title="Inicio"
href="/"
className={`text-truncate ${expanded ? "mt-3" : ""}`}
onClick={() => setExpanded(false)}
>
<FontAwesomeIcon icon={faHouse} className="me-2" />
Inicio
</Nav.Link>
<div className="d-lg-none mt-2 ms-2">
<ThemeButton onlyIcon={isLg} />
</div>
</Nav>
</Navbar.Collapse>
<div className="d-none d-lg-block me-3">
<ThemeButton onlyIcon={isLg} />
</div>
<Nav className="d-flex flex-md-row flex-column gap-2 ms-auto align-items-center">
<IfAuthenticated>
<AnimatedDropdown
className='end-0 position-absolute'
show={showingUserDropdown}
onMouseEnter={() => setShowingUserDropdown(true)}
onMouseLeave={() => setShowingUserDropdown(false)}
onToggle={(isOpen) => setShowingUserDropdown(isOpen)}
trigger={
<Link className="nav-link dropdown-toggle fw-bold">
@{user?.user_name}
</Link>
}
>
<Link to="/perfil" className="text-muted dropdown-item nav-link">
<FontAwesomeIcon icon={faUser} className="me-2" />
Mi perfil
</Link>
<hr className="dropdown-divider" />
<Link to="#" className="dropdown-item nav-link" onClick={logout}>
<FontAwesomeIcon icon={faSignOut} className="me-2" />
Cerrar sesión
</Link>
</AnimatedDropdown>
</IfAuthenticated>
<IfNotAuthenticated>
<Nav.Link as={Link} to="/login" title="Iniciar sesión">
<FontAwesomeIcon icon={faSignIn} className="me-2" />
Iniciar sesión
</Nav.Link>
</IfNotAuthenticated>
</Nav>
</Container>
</Navbar>
);
};
export default NavBar;

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,43 @@
import { faFilter, faFilePdf, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import AnimatedDropdown from '@components/AnimatedDropdown';
import Button from 'react-bootstrap/Button';
import { CONSTANTS } from '@/util/constants';
import IfRole from '@/components/Auth/IfRole';
const SearchToolbar = ({ searchTerm, onSearchChange, filtersComponent, onCreate, onPDF }) => (
<div className="sticky-toolbar search-toolbar-wrapper">
<div className="search-toolbar">
<input
type="text"
className="search-input"
placeholder="Buscar..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
<div className="toolbar-buttons">
{filtersComponent && (
<AnimatedDropdown variant="transparent" icon={<FontAwesomeIcon icon={faFilter} className='fa-md' />}>
{filtersComponent}
</AnimatedDropdown>
)}
{onPDF && (
<IfRole roles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
<Button variant="transparent" onClick={onPDF}>
<FontAwesomeIcon icon={faFilePdf} className='fa-md' />
</Button>
</IfRole>
)}
{onCreate && (
<IfRole roles={[CONSTANTS.ROLE_ADMIN, CONSTANTS.ROLE_DEV]}>
<Button variant="transparent" onClick={onCreate}>
<FontAwesomeIcon icon={faPlus} className='fa-md' />
</Button>
</IfRole>
)}
</div>
</div>
</div>
);
export default SearchToolbar;

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>
);
}

View File

@@ -0,0 +1,98 @@
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 [user, setUser] = useState(() => JSON.parse(localStorage.getItem("user")) || null);
const [token, setToken] = useState(() => localStorage.getItem("token"));
const [authStatus, setAuthStatus] = useState("checking");
const [error, setError] = useState(null);
useEffect(() => {
if (!config) return;
if (!token) {
setAuthStatus("unauthenticated");
return;
}
const BASE_URL = config.apiConfig.baseUrl;
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, member, tokenTime } = res.data.data;
localStorage.setItem("token", token);
localStorage.setItem("user", JSON.stringify(member));
localStorage.setItem("tokenTime", tokenTime);
setToken(token);
setUser(member);
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 ha sido 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.clear();
setUser(null);
setToken(null);
setAuthStatus("unauthenticated");
};
return (
<AuthContext.Provider value={{ user, token, authStatus, login, 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, children }) => {
const data = useData(config);
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,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

@@ -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;
}

51
src/css/Building.css Normal file
View File

@@ -0,0 +1,51 @@
/* ================================
BUILDING COMPONENT - VISUAL ONLY
================================== */
.building-container {
font-family: 'Product Sans', sans-serif;
color: var(--fg-color);
animation: fadeInScale 0.5s ease;
}
.building-icon {
font-size: 4rem;
margin-bottom: 1rem;
animation: bounce 2s infinite;
}
.building-title {
font-size: 2rem;
font-weight: bold;
}
.building-subtitle {
font-size: 1.2rem;
margin-top: 0.5rem;
opacity: 0.75;
}
/* Animaciones */
@keyframes fadeInScale {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes bounce {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-8px);
}
}

View File

@@ -0,0 +1,11 @@
.carousel-img-wrapper {
padding: 0.5rem;
}
.carousel-img {
width: 100%;
height: auto;
border-radius: 1rem;
max-height: 60vh;
object-fit: cover;
}

149
src/css/Footer.css Normal file
View File

@@ -0,0 +1,149 @@
.footer {
background-color: var(--navbar-bg); /* más similar al navbar */
color: var(--fg-color);
border-top: 3px solid var(--border-color);
font-size: 1rem;
box-shadow: 0 -2px 8px var(--shadow-color);
position: relative;
z-index: 1;
}
.footer::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 12px;
background-color: var(--primary-color);
opacity: 0.25;
pointer-events: none;
}
.footer-title,
.footer h6#devd {
font-family: "Product Sans";
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 1rem;
color: var(--primary-color);
}
.footer-columns {
display: flex;
flex-direction: column;
gap: 2rem;
margin-bottom: 2rem;
background-color: var(--bg-hover-color); /* sutil contraste */
padding: 1.5rem;
border-radius: 1rem;
box-shadow: 0 4px 10px rgba(0,0,0,0.04);
}
@media (min-width: 768px) {
.footer-columns {
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
}
.footer-column {
flex: 1;
min-width: 200px;
}
.footer-column h5 {
font-size: 1.1rem;
margin-bottom: 0.75rem;
font-weight: 600;
color: var(--fg-color);
}
.footer-column ul {
list-style: none;
padding: 0;
margin: 0;
}
.footer-column ul li {
margin-bottom: 0.5rem;
}
.footer-column ul li a {
color: var(--fg-color);
text-decoration: none;
}
.footer-column ul li a:hover {
color: var(--primary-color);
text-shadow: 0 0 4px currentColor;
}
.footer-bottom {
font-size: 0.9rem;
opacity: 0.85;
text-align: center;
border-top: 1px solid var(--divider-color);
}
.footer-bottom a {
font-weight: 600;
text-decoration: none;
color: var(--fg-color);
}
.footer-bottom a:hover {
text-shadow: 0 0 5px currentColor;
color: var(--primary-color);
}
.contact-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1rem;
border-radius: 1rem;
font-size: 0.95rem;
background-color: var(--contact-info-bg);
color: var(--primary-color);
box-shadow: 0 4px 10px var(--shadow-color);
}
.contact-info a {
font-weight: 600;
text-decoration: none;
color: var(--primary-color);
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.contact-info a:hover {
transform: translateX(8px);
color: var(--secondary-color);
}
.contact-info .fa-icon {
font-size: 1.2rem;
color: var(--primary-color);
}
.heart-anim {
display: inline-block;
animation: heartbeat 1.5s infinite ease-in-out;
}
@keyframes heartbeat {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.15);
}
}

43
src/css/Header.css Normal file
View File

@@ -0,0 +1,43 @@
/* ================================
HEADER - ESTILO BASE
================================== */
.bg-img {
background-image: url('/images/bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
/*.mask {
background-color: var(--header-mask-color);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
*/
.header-title {
font-family: 'Product Sans';
font-size: 3em;
font-weight: bolder;
color: var(--text-color);
}
.shadowed {
text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5);
}
/* ================================
RESPONSIVE HEADER TITLE
================================== */
@media (max-width: 768px) {
.header-title {
font-size: 2em;
padding: 0 1rem;
text-align: center;
}
}

0
src/css/Home.css Normal file
View File

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

@@ -0,0 +1,53 @@
/* ================================
LOGIN - CARD CONTAINER (VISUAL)
================================== */
.login-card {
background-color: var(--login-bg) !important;
color: var(--text-color);
box-shadow: 0 0 10px var(--shadow-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;
}
/* ================================
BOTÓN VISUAL
================================== */
.login-button {
font-family: 'Product Sans' !important;
font-size: 1.3em !important;
font-weight: bold !important;
background-color: var(--login-btn-bg) !important;
color: var(--login-btn-text) !important;
}
.login-button:hover {
background-color: var(--login-btn-hover) !important;
color: var(--login-btn-text-hover) !important;
}

57
src/css/NavBar.css Normal file
View File

@@ -0,0 +1,57 @@
/* ================================
NAVBAR - VISUAL + THEMING ONLY
================================== */
.navbar {
background-color: var(--navbar-bg) !important;
box-shadow: var(--navbar-shadow);
z-index: 1000;
}
.navbar-brand {
color: var(--navbar-brand-color) !important;
}
.navbar-brand:hover {
color: var(--navbar-brand-hover) !important;
}
a.nav-link,
.nav-item > a.nav-link,
.dropdown-item {
font-family: "Product Sans";
font-size: larger;
border-radius: 8px;
padding: 0.5rem 1rem;
color: var(--navbar-link-color) !important;
}
.nav-link:hover,
.nav-link:focus {
background-color: var(--navbar-link-hover-bg) !important;
color: var(--navbar-link-hover-color) !important;
}
hr {
border-top: 1px solid var(--navbar-divider-color);
}
/* ================================
ANIMACIONES
================================== */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

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

61
src/css/ThemeButton.css Normal file
View File

@@ -0,0 +1,61 @@
/* ================================
THEME TOGGLE - BASE
================================== */
.theme-toggle {
width: auto;
height: 40px;
border: none;
border-radius: 999px;
display: flex;
padding: 0 1rem;
align-items: center;
justify-content: center;
cursor: pointer;
outline: none;
background-color: var(--toggle-bg);
color: var(--toggle-fg);
font-family: 'Product Sans';
font-size: 1.2rem;
transition:
background-color 0.3s ease,
color 0.3s ease,
transform 0.2s ease,
box-shadow 0.3s ease;
}
/* ================================
HOVER / ACTIVE STATES
================================== */
.theme-toggle:hover {
transform: scale(1.05);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2);
}
/* ================================
LIGHT THEME
================================== */
.light {
--toggle-bg: #1e1e1e;
--toggle-fg: #f0f0f0;
}
.light .theme-toggle {
box-shadow: 0 0px 10px rgba(68, 7, 182, 0.808);
}
/* ================================
DARK THEME
================================== */
.dark {
--toggle-bg: #f0f0f0;
--toggle-fg: #1e1e1e;
}
.dark .theme-toggle {
box-shadow: 0 0px 10px rgba(206, 180, 36, 0.589);
}

230
src/css/index.css Normal file
View File

@@ -0,0 +1,230 @@
/* ================================
FUENTES PERSONALIZADAS
================================== */
@font-face {
font-family: "Open Sans";
src: url('/fonts/OpenSans.ttf');
}
@font-face {
font-family: "Product Sans";
src: url('/fonts/ProductSansRegular.ttf');
}
@font-face {
font-family: "Product Sans Italic";
src: url('/fonts/ProductSansItalic.ttf');
}
@font-face {
font-family: "Product Sans Italic Bold";
src: url('/fonts/ProductSansBoldItalic.ttf');
}
@font-face {
font-family: "Product Sans Bold";
src: url('/fonts/ProductSansBold.ttf');
}
/* ================================
PALETA DE COLORES
================================== */
:root {
--highlight-border: var(--accent-color);
--box-shadow-soft: 0 4px 6px var(--shadow-color);
--alert-bg: #f8d7da;
}
.light {
--primary-color: #333;
--secondary-color: #555;
--tertiary-color: #777;
--border-color: #ccc;
--divider-color: #ddd;
--bg-color: #fff;
--fg-color: #111;
--text-color: #111;
--muted-color: #666;
--shadow-color: rgba(0, 0, 0, 0.1);
--bg-hover-color: #f0f0f0;
--bg-search-bar: rgba(0,0,0,0.05);
--input-bg: #fff;
--input-border: #ccc;
--placeholder-color: #999;
--input-text: var(--text-color);
--accent-color: #333;
--btn-bg: #333;
--btn-bg-hover: #555;
--btn-text: #fff;
--btn-text-hover: #fff;
--icon-color: var(--fg-color);
--highlight-border: #777;
--card-bg: #fff;
--card-button: #fff;
--card-border: #ccc;
--card-text: var(--text-color);
--card-text-secondary: #555;
--card-btn-hover: rgba(0,0,0,0.05);
--card-muted-text: #666;
--item-bg: #fff;
--item-text: var(--text-color);
--subtitle-color: #666;
--login-bg: #f9f9f9;
--label-color: var(--text-color);
--login-btn-bg: #333;
--login-btn-hover: #555;
--login-btn-text: #fff;
--login-btn-text-hover: #111;
--header-mask-color: rgba(0,0,0,0.1);
--navbar-bg: #fff;
--navbar-brand-color: #333;
--navbar-brand-hover: #555;
--navbar-link-color: #111;
--navbar-link-hover-bg: #f0f0f0;
--navbar-link-hover-color: #333;
--navbar-dropdown-bg: #fff;
--navbar-dropdown-item-color: #111;
--navbar-dropdown-item-hover-color: #333;
--navbar-divider-color: #ccc;
--hamburger-color: #333;
--navbar-shadow: 0 2px 6px rgba(0,0,0,0.05);
--show-btn-color: #333;
--show-btn-hover: #555;
--header-btn-hover: rgba(0,0,0,0.05);
--list-hover-bg: rgba(0,0,0,0.03);
--list-hover-bg-light: #f5f5f5;
--list-active-bg-light: #e0e0e0;
--search-bg: rgba(255,255,255,0.6);
--search-border: #ccc;
--search-input-color: #111;
--search-placeholder: #999;
--toolbar-btn-color: #111;
--toolbar-btn-hover: rgba(0,0,0,0.07);
--modal-bg: #fff;
--modal-header-border: #ccc;
--modal-body-bg: #fff;
--modal-close-color: #111;
--contact-info-bg: #f5f5f5;
--balance-report-bg: #fff;
--file-card-bg: #fff;
--sidebar-bg: #eee;
}
.dark {
--primary-color: #eee;
--secondary-color: #ccc;
--tertiary-color: #999;
--border-color: #444;
--divider-color: #555;
--bg-color: #111;
--fg-color: #fff;
--text-color: #fff;
--muted-color: #aaa;
--shadow-color: rgba(0,0,0,0.5);
--bg-hover-color: #222;
--bg-search-bar: rgba(255,255,255,0.05);
--input-bg: #222;
--input-border: #555;
--placeholder-color: #888;
--input-text: #fff;
--accent-color: #eee;
--btn-bg: #eee;
--btn-bg-hover: #ccc;
--btn-text: #111;
--btn-text-hover: #000;
--icon-color: #fff;
--highlight-border: #999;
--alert-bg: #500;
--card-bg: #222;
--card-button: #222;
--card-border: #555;
--card-text: #fff;
--card-text-secondary: #ccc;
--card-btn-hover: rgba(255,255,255,0.05);
--item-bg: #222;
--item-text: #fff;
--subtitle-color: #aaa;
--login-bg: #111;
--label-color: #fff;
--login-btn-bg: #eee;
--login-btn-hover: #ccc;
--login-btn-text: #111;
--login-btn-text-hover: #000;
--header-mask-color: rgba(0,0,0,0.3);
--navbar-bg: #111;
--navbar-brand-color: #eee;
--navbar-brand-hover: #ccc;
--navbar-link-color: #fff;
--navbar-link-hover-bg: #222;
--navbar-link-hover-color: #ccc;
--navbar-dropdown-bg: #222;
--navbar-dropdown-item-color: #fff;
--navbar-dropdown-item-hover-color: #ccc;
--navbar-divider-color: #555;
--hamburger-color: #eee;
--navbar-shadow: 0 2px 5px rgba(0,0,0,0.5);
--show-btn-color: #eee;
--show-btn-hover: #ccc;
--card-muted-text: #aaa;
--header-btn-hover: rgba(255,255,255,0.05);
--list-hover-bg: rgba(255,255,255,0.03);
--list-hover-bg-dark: #333;
--list-active-bg-dark: #444;
--search-bg: rgba(255,255,255,0.1);
--search-border: #555;
--search-input-color: #fff;
--search-placeholder: #888;
--toolbar-btn-color: #fff;
--toolbar-btn-hover: rgba(255,255,255,0.08);
--modal-bg: #222;
--modal-header-border: #555;
--modal-body-bg: #222;
--modal-close-color: #fff;
--contact-info-bg: #111;
--balance-report-bg: #222;
--file-card-bg: #222;
--sidebar-bg: #000;
}
/* ================================
ESTILOS BASE / RESET SUAVE
================================== */
html,
body {
font-family: "Open Sans", sans-serif;
color: var(--text-color);
background-color: var(--bg-color);
}
body {
background-color: transparent !important; /* compatibilidad navbar fija */
}
main {
color: var(--text-color);
background-color: var(--bg-color);
}
/* Tipografía global */
div,
label,
input,
p,
span,
a,
button {
font-family: "Open Sans", sans-serif;
color: var(--text-color);
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Product Sans", sans-serif;
color: var(--text-color);
}

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);

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

@@ -0,0 +1,130 @@
import { useState, useEffect, useCallback, useRef } from "react";
import axios from "axios";
export const useData = (config) => {
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 = () => ({
"Content-Type": "application/json",
"Authorization": `Bearer ${localStorage.getItem("token")}`,
});
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.data);
} catch (err) {
setError(err.response?.data?.message || err.message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (config?.baseUrl) {
fetchData();
}
}, [config, fetchData]);
const getData = async (url, params = {}) => {
try {
const response = await axios.get(url, {
headers: getAuthHeaders(),
params,
});
return { data: response.data.data, error: null };
} catch (err) {
return {
data: null,
error: err.response?.data?.message || err.message,
};
}
};
const postData = async (endpoint, payload) => {
const headers = {
Authorization: `Bearer ${localStorage.getItem("token")}`,
...(payload instanceof FormData ? {} : { "Content-Type": "application/json" }),
};
const response = await axios.post(endpoint, payload, { headers });
await fetchData();
return response.data.data;
};
const postDataValidated = async (endpoint, payload) => {
try {
const headers = {
Authorization: `Bearer ${localStorage.getItem("token")}`,
...(payload instanceof FormData ? {} : { "Content-Type": "application/json" }),
};
const response = await axios.post(endpoint, payload, { headers });
return { data: response.data.data, errors: null };
} catch (err) {
const raw = err.response?.data?.message;
let parsed = {};
try {
parsed = JSON.parse(raw);
} catch {
return { data: null, errors: { general: raw || err.message } };
}
return { data: null, errors: parsed };
}
};
const putData = async (endpoint, payload) => {
const response = await axios.put(endpoint, payload, {
headers: getAuthHeaders(),
});
await fetchData();
return response.data.data;
};
const deleteData = async (endpoint) => {
const response = await axios.delete(endpoint, {
headers: getAuthHeaders(),
});
await fetchData();
return response.data.data;
};
const deleteDataWithBody = async (endpoint, payload) => {
const response = await axios.delete(endpoint, {
headers: getAuthHeaders(),
data: payload,
});
await fetchData();
return response.data.data;
};
return {
data,
dataLoading,
dataError,
getData,
postData,
postDataValidated,
putData,
deleteData,
deleteDataWithBody,
};
};

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,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); // revisa cada 10 segundos
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.baseUrl}${config.apiConfig.endpoints.auth.refreshToken}`,
null,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const newToken = response.data.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;
};

30
src/main.jsx Normal file
View File

@@ -0,0 +1,30 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
/* COMPONENTS */
import App from '@/App.jsx'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider } from '@/context/ThemeContext'
import { AuthProvider } from '@/context/AuthContext'
import { ConfigProvider } from '@/context/ConfigContext.jsx'
/* CSS */
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import '@/css/index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<ConfigProvider>
<ThemeProvider>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</ThemeProvider>
</ConfigProvider>
</StrictMode>
)

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

@@ -0,0 +1,17 @@
import { useLocation } from 'react-router-dom';
import '@/css/Building.css';
export default function Building() {
const location = useLocation();
if (location.pathname === '/') return null;
return (
<div className="building-container d-flex flex-column align-items-center justify-content-center text-center py-5 px-3">
<div className="building-icon">🚧</div>
<div className="building-title">Esta página está en construcción</div>
<div className="building-subtitle">Estamos trabajando para traértela pronto</div>
</div>
);
}

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

@@ -0,0 +1,21 @@
import React from 'react';
import { Link } from 'react-router-dom';
import '@/css/Home.css';
import CustomContainer from '@/components/CustomContainer';
import ContentWrapper from '@/components/ContentWrapper';
import CustomCarousel from '@/components/CustomCarousel';
const Home = () => {
return (
<CustomContainer>
<h1>Título aquí 🐧</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam eu tristique elit. Nullam lorem turpis, cursus in augue id, dignissim rhoncus dolor. Suspendisse vitae elit et orci auctor rutrum. Sed lobortis iaculis dapibus. Mauris vel dapibus elit. In risus est, lobortis ut scelerisque at, luctus pretium lorem. Vestibulum condimentum erat ut sagittis commodo. Nulla facilisi. Praesent vel dolor molestie, molestie ipsum ut, efficitur est. Nulla rutrum pulvinar eros, a faucibus orci vehicula sed. In tincidunt in sapien vel convallis. Aliquam nec leo sit amet libero efficitur imperdiet ac sit amet leo. Vivamus ex tellus, tempor aliquam orci eu, eleifend tempus odio.
</p><p>
Aliquam mollis sollicitudin pharetra. Quisque malesuada, nulla nec sodales consequat, nulla felis imperdiet metus, auctor aliquet lacus urna vel neque. Cras cursus nisl eu erat vehicula, sed semper turpis porttitor. Fusce ut lectus a erat gravida ullamcorper ut ut neque. Pellentesque rutrum, nibh vitae egestas ullamcorper, justo dolor hendrerit magna, in rhoncus ante magna at velit. Fusce cursus, ante sed dictum gravida, ex lacus pulvinar libero, quis egestas sapien leo id nisi. In eget vestibulum ante. Vivamus venenatis eros lorem, ac tincidunt tortor elementum at.
</p>
</CustomContainer>
);
};
export default Home;

15
src/util/alertHelpers.jsx Normal file
View File

@@ -0,0 +1,15 @@
export const renderErrorAlert = (error, options = {}) => {
const { className = 'alert alert-danger py-1 px-2 small', role = 'alert' } = options;
if (!error) return null;
return (
<div className={className} role={role}>
{typeof error === 'string' ? error : 'An unexpected error occurred.'}
</div>
);
};
export const resetErrorIfEditEnds = (editMode, setError) => {
if (!editMode) setError(null);
};

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

@@ -0,0 +1,7 @@
'use strict';
const CONSTANTS = {
};
export { CONSTANTS };

10
src/util/date.js Normal file
View File

@@ -0,0 +1,10 @@
'use strict';
const getNowAsLocalDatetime = () => {
const now = new Date();
const offset = now.getTimezoneOffset(); // en minutos
const local = new Date(now.getTime() - offset * 60000);
return local.toISOString().slice(0, 16);
};
export { getNowAsLocalDatetime }

View File

@@ -0,0 +1,30 @@
export const DateParser = {
sqlToString: (sqlDate) => {
const [datePart] = sqlDate.split('T');
const [year, month, day] = datePart.split('-');
return `${day}/${month}/${year}`;
},
timestampToString: (timestamp) => {
const [datePart] = timestamp.split('T');
const [year, month, day] = datePart.split('-');
return `${day}/${month}/${year}`;
},
isoToStringWithTime: (isoString) => {
if (!isoString) return '—';
const date = new Date(isoString);
if (isNaN(date)) return '—'; // Para proteger aún más por si llega basura
return new Intl.DateTimeFormat('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'Europe/Madrid'
}).format(date);
}
};

View File

@@ -0,0 +1,10 @@
export const errorParser = (err) => {
const message = err.response?.data?.message;
try {
const parsed = JSON.parse(message);
return Object.values(parsed)[0];
// eslint-disable-next-line no-unused-vars
} catch (e) {
return message || err.message || "Unknown error";
}
};

View File

@@ -0,0 +1,29 @@
export const generateSecurePassword = (length = 12) => {
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const lower = 'abcdefghijklmnopqrstuvwxyz';
const digits = '0123456789';
const symbols = '!@#$%^&*'; // <- compatibles con bcrypt
const all = upper + lower + digits + symbols;
if (length < 8) length = 8;
const getRand = (chars) => chars[Math.floor(Math.random() * chars.length)];
let password = [
getRand(upper),
getRand(lower),
getRand(digits),
getRand(symbols),
];
for (let i = password.length; i < length; i++) {
password.push(getRand(all));
}
for (let i = password.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[password[i], password[j]] = [password[j], password[i]];
}
return password.join('');
};

7
src/util/tokenUtils.js Normal file
View File

@@ -0,0 +1,7 @@
export const parseJwt = (token) => {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
return null;
}
};