generated from Gallardo7761/miarma-template-full
Initial commit
This commit is contained in:
28
src/App.jsx
Normal file
28
src/App.jsx
Normal 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
14
src/api/axiosInstance.js
Normal 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;
|
||||
92
src/components/AnimatedDropdown.jsx
Normal file
92
src/components/AnimatedDropdown.jsx
Normal 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;
|
||||
122
src/components/AnimatedDropend.jsx
Normal file
122
src/components/AnimatedDropend.jsx
Normal 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;
|
||||
8
src/components/Auth/IfAuthenticated.jsx
Normal file
8
src/components/Auth/IfAuthenticated.jsx
Normal 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;
|
||||
8
src/components/Auth/IfNotAuthenticated.jsx
Normal file
8
src/components/Auth/IfNotAuthenticated.jsx
Normal 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;
|
||||
13
src/components/Auth/IfRole.jsx
Normal file
13
src/components/Auth/IfRole.jsx
Normal 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;
|
||||
120
src/components/Auth/LoginForm.jsx
Normal file
120
src/components/Auth/LoginForm.jsx
Normal 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;
|
||||
48
src/components/Auth/PasswordInput.jsx
Normal file
48
src/components/Auth/PasswordInput.jsx
Normal 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;
|
||||
18
src/components/Auth/ProtectedRoute.jsx
Normal file
18
src/components/Auth/ProtectedRoute.jsx
Normal 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;
|
||||
15
src/components/ContentWrapper.jsx
Normal file
15
src/components/ContentWrapper.jsx
Normal 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;
|
||||
47
src/components/CustomCarousel.jsx
Normal file
47
src/components/CustomCarousel.jsx
Normal 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;
|
||||
15
src/components/CustomContainer.jsx
Normal file
15
src/components/CustomContainer.jsx
Normal 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;
|
||||
26
src/components/CustomModal.jsx
Normal file
26
src/components/CustomModal.jsx
Normal 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
56
src/components/Footer.jsx
Normal 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
19
src/components/Header.jsx
Normal 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;
|
||||
10
src/components/LoadingIcon.jsx
Normal file
10
src/components/LoadingIcon.jsx
Normal 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
133
src/components/NavBar.jsx
Normal 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;
|
||||
69
src/components/NotificationModal.jsx
Normal file
69
src/components/NotificationModal.jsx
Normal 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;
|
||||
43
src/components/SearchToolbar.jsx
Normal file
43
src/components/SearchToolbar.jsx
Normal 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;
|
||||
18
src/components/ThemeButton.jsx
Normal file
18
src/components/ThemeButton.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/context/AuthContext.jsx
Normal file
98
src/context/AuthContext.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
41
src/context/ConfigContext.jsx
Normal file
41
src/context/ConfigContext.jsx
Normal 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};
|
||||
23
src/context/DataContext.jsx
Normal file
23
src/context/DataContext.jsx
Normal 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,
|
||||
};
|
||||
31
src/context/ThemeContext.jsx
Normal file
31
src/context/ThemeContext.jsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
src/css/AnimatedDropdown.css
Normal file
28
src/css/AnimatedDropdown.css
Normal 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
51
src/css/Building.css
Normal 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);
|
||||
}
|
||||
}
|
||||
11
src/css/CustomCarousel.css
Normal file
11
src/css/CustomCarousel.css
Normal 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
149
src/css/Footer.css
Normal 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
43
src/css/Header.css
Normal 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
0
src/css/Home.css
Normal file
53
src/css/LoginForm.css
Normal file
53
src/css/LoginForm.css
Normal 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
57
src/css/NavBar.css
Normal 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);
|
||||
}
|
||||
}
|
||||
7
src/css/PasswordInput.css
Normal file
7
src/css/PasswordInput.css
Normal 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
61
src/css/ThemeButton.css
Normal 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
230
src/css/index.css
Normal 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
4
src/hooks/useAuth.js
Normal 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
4
src/hooks/useConfig.js
Normal 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
130
src/hooks/useData.js
Normal 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,
|
||||
};
|
||||
};
|
||||
4
src/hooks/useDataContext.js
Normal file
4
src/hooks/useDataContext.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useContext } from "react";
|
||||
import { DataContext } from "@/context/DataContext";
|
||||
|
||||
export const useDataContext = () => useContext(DataContext);
|
||||
103
src/hooks/useSessionRenewal.jsx
Normal file
103
src/hooks/useSessionRenewal.jsx
Normal 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
10
src/hooks/useTheme.js
Normal 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
30
src/main.jsx
Normal 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
17
src/pages/Building.jsx
Normal 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
21
src/pages/Home.jsx
Normal 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
15
src/util/alertHelpers.jsx
Normal 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
7
src/util/constants.js
Normal file
@@ -0,0 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const CONSTANTS = {
|
||||
|
||||
};
|
||||
|
||||
export { CONSTANTS };
|
||||
10
src/util/date.js
Normal file
10
src/util/date.js
Normal 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 }
|
||||
30
src/util/parsers/dateParser.js
Normal file
30
src/util/parsers/dateParser.js
Normal 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);
|
||||
}
|
||||
};
|
||||
10
src/util/parsers/errorParser.js
Normal file
10
src/util/parsers/errorParser.js
Normal 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";
|
||||
}
|
||||
};
|
||||
29
src/util/passwordGenerator.js
Normal file
29
src/util/passwordGenerator.js
Normal 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
7
src/util/tokenUtils.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const parseJwt = (token) => {
|
||||
try {
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user