Add: full functionality
This commit is contained in:
9
jsconfig.json
Normal file
9
jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -15,13 +15,16 @@
|
|||||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||||
|
"axios": "^1.13.4",
|
||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
|
"boring-avatars": "^2.0.4",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"framer-motion": "^12.11.0",
|
"framer-motion": "^12.11.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-bootstrap": "^2.10.10",
|
"react-bootstrap": "^2.10.10",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.9.6"
|
"react-router-dom": "^7.13.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
|
|||||||
66
public/config/settings.dev.json
Normal file
66
public/config/settings.dev.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"apiConfig": {
|
||||||
|
"baseUrl": "http://localhost:8080/v2/core",
|
||||||
|
"endpoints": {
|
||||||
|
"auth": {
|
||||||
|
"login": "/auth/login",
|
||||||
|
"register": "/auth/register",
|
||||||
|
"refreshToken": "/auth/refresh",
|
||||||
|
"changePassword": "/auth/change-password",
|
||||||
|
"validateToken": "/auth/validate"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"all": "/users",
|
||||||
|
"byId": "/users/:userId",
|
||||||
|
"avatar": "/users/:userId/avatar",
|
||||||
|
"status": "/users/:userId/status",
|
||||||
|
"role": "/users/:userId/role",
|
||||||
|
"exists": "/users/:userId/exists",
|
||||||
|
"me": "/users/me"
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"all": "/credentials",
|
||||||
|
"byId": "/credentials/:credentialId",
|
||||||
|
"byUserId": "/credentials/user/:userId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"title": "Portafolio",
|
||||||
|
"link": "https://jose.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MiarmaGit",
|
||||||
|
"link": "https://git.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MiarmaPaste",
|
||||||
|
"link": "https://paste.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MiarmaCraft",
|
||||||
|
"link": "https://mc.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MiarmaDrive",
|
||||||
|
"link": "https://drive.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MiarmaMedia",
|
||||||
|
"link": "https://cine.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MiarmaDocs",
|
||||||
|
"link": "https://docs.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Huertos Bellavista",
|
||||||
|
"link": "https://www.huertosbellavista.es"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Huertos de Cine",
|
||||||
|
"link": "https://cine.huertosbellavista.es"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"title": "Portafolio",
|
|
||||||
"link": "https://jose.miarma.net"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "MiarmaGit",
|
|
||||||
"link": "https://git.miarma.net"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "MiarmaPaste",
|
|
||||||
"link": "https://paste.miarma.net"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "ETSIIMC",
|
|
||||||
"link": "https://miarma.net/etsiimc"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Status",
|
|
||||||
"link": "https://status.miarma.net"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Panel",
|
|
||||||
"link": "https://Panel.miarma.net"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "MiarmaDrive",
|
|
||||||
"link": "https://drive.miarma.net"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Multimedia",
|
|
||||||
"link": "https://cine.miarma.net"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Docs",
|
|
||||||
"link": "https://docs.miarma.net"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Huertos Bellavista",
|
|
||||||
"link": "https://www.huertosbellavista.es"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Huertos de Cine",
|
|
||||||
"link": "https://cine.huertosbellavista.es"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
66
public/config/settings.prod.json
Normal file
66
public/config/settings.prod.json
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"apiConfig": {
|
||||||
|
"baseUrl": "https://api.miarma.net/v2/core",
|
||||||
|
"endpoints": {
|
||||||
|
"auth": {
|
||||||
|
"login": "/auth/login",
|
||||||
|
"register": "/auth/register",
|
||||||
|
"refreshToken": "/auth/refresh",
|
||||||
|
"changePassword": "/auth/change-password",
|
||||||
|
"validateToken": "/auth/validate"
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"all": "/users",
|
||||||
|
"byId": "/users/:userId",
|
||||||
|
"avatar": "/users/:userId/avatar",
|
||||||
|
"status": "/users/:userId/status",
|
||||||
|
"role": "/users/:userId/role",
|
||||||
|
"exists": "/users/:userId/exists",
|
||||||
|
"me": "/users/me"
|
||||||
|
},
|
||||||
|
"credentials": {
|
||||||
|
"all": "/credentials",
|
||||||
|
"byId": "/credentials/:credentialId",
|
||||||
|
"byUserId": "/credentials/user/:userId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"title": "Portafolio",
|
||||||
|
"link": "https://jose.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MiarmaGit",
|
||||||
|
"link": "https://git.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MiarmaPaste",
|
||||||
|
"link": "https://paste.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MiarmaCraft",
|
||||||
|
"link": "https://mc.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "MiarmaDrive",
|
||||||
|
"link": "https://drive.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Multimedia",
|
||||||
|
"link": "https://cine.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Docs",
|
||||||
|
"link": "https://docs.miarma.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Huertos Bellavista",
|
||||||
|
"link": "https://www.huertosbellavista.es"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Huertos de Cine",
|
||||||
|
"link": "https://cine.huertosbellavista.es"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
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;
|
||||||
130
src/components/Accounts/AccountCard.jsx
Normal file
130
src/components/Accounts/AccountCard.jsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// AccountCard.jsx
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { CONSTANTS } from "@/util/constants.js";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faPen, faCheck, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
const getServiceName = (serviceId) => {
|
||||||
|
switch (serviceId) {
|
||||||
|
case CONSTANTS.CORE_ID: return "Miarma";
|
||||||
|
case CONSTANTS.HUERTOS_ID: return "Huertos Bellavista";
|
||||||
|
case CONSTANTS.MINECRAFT_ID: return "MiarmaCraft";
|
||||||
|
case CONSTANTS.CINE_ID: return "Huertos de Cine";
|
||||||
|
case CONSTANTS.MPASTE_ID: return "MPaste";
|
||||||
|
default: return "Desconocido";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const AccountCard = ({ identity, onUpdate, onRequestStatusChange, confirmedStatusChange }) => {
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: identity.username,
|
||||||
|
email: identity.email,
|
||||||
|
status: identity.status
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editMode) {
|
||||||
|
setFormData({
|
||||||
|
username: identity.username,
|
||||||
|
email: identity.email,
|
||||||
|
status: identity.status
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [identity, editMode]);
|
||||||
|
|
||||||
|
// Aplica la desactivación confirmada
|
||||||
|
useEffect(() => {
|
||||||
|
if (confirmedStatusChange?.credentialId === identity.credentialId) {
|
||||||
|
setFormData(prev => ({ ...prev, status: confirmedStatusChange.status }));
|
||||||
|
}
|
||||||
|
}, [confirmedStatusChange, identity.credentialId]);
|
||||||
|
|
||||||
|
const handleChange = (field, value) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onUpdate?.({ ...identity, ...formData });
|
||||||
|
setEditMode(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => setEditMode(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card shadow-sm h-100">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div className="m-0 p-0 d-flex flex-column">
|
||||||
|
<span className={`fw-semibold ${identity.status == 0 ? "text-danger" : ""}`}>
|
||||||
|
{getServiceName(identity.serviceId)}
|
||||||
|
{identity.status == 0 && (
|
||||||
|
<span className="text-danger small">
|
||||||
|
(Se eliminará en 2 meses tras desactivación)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<small className="muted">{identity.credentialId}</small>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex gap-3">
|
||||||
|
{identity.status != 0 && (
|
||||||
|
!editMode ? (
|
||||||
|
<FontAwesomeIcon icon={faPen} className="cursor-pointer" onClick={() => setEditMode(true)} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="text-success cursor-pointer" onClick={handleSave} />
|
||||||
|
<FontAwesomeIcon icon={faXmark} className="text-danger cursor-pointer" onClick={handleCancel} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-body d-flex flex-column gap-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted small">Usuario</span>
|
||||||
|
{editMode ? (
|
||||||
|
<input className="form-control form-control-sm themed-input" value={formData.username} onChange={e => handleChange("username", e.target.value)} />
|
||||||
|
) : <div className="fw-semibold">{identity.username}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-muted small">Email</span>
|
||||||
|
{editMode ? (
|
||||||
|
<input className="form-control form-control-sm themed-input" value={formData.email} onChange={e => handleChange("email", e.target.value)} />
|
||||||
|
) : <div>{identity.email}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto d-flex justify-content-between align-items-center">
|
||||||
|
{editMode ? (
|
||||||
|
<select className="form-select form-select-sm themed-input"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={e => onRequestStatusChange?.(identity, parseInt(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={1}>Activa</option>
|
||||||
|
<option value={0}>Inactiva</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className={`badge ${identity.status === 1 ? "bg-success" : "bg-danger"}`}>
|
||||||
|
{identity.status === 1 ? "Activa" : "Inactiva"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="text-muted small ms-2">
|
||||||
|
Creada el: {dayjs(identity.updatedAt).format("DD/MM/YYYY")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AccountCard.propTypes = {
|
||||||
|
identity: PropTypes.object.isRequired,
|
||||||
|
onUpdate: PropTypes.func,
|
||||||
|
onRequestStatusChange: PropTypes.func,
|
||||||
|
confirmedStatusChange: PropTypes.object
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountCard;
|
||||||
91
src/components/AnimatedDropdown.jsx
Normal file
91
src/components/AnimatedDropdown.jsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useState, useRef, useEffect, cloneElement } from 'react';
|
||||||
|
import { Button } from 'react-bootstrap';
|
||||||
|
import { AnimatePresence, motion as _motion } from 'framer-motion';
|
||||||
|
import '@/css/AnimatedDropdown.css';
|
||||||
|
|
||||||
|
const AnimatedDropdown = ({
|
||||||
|
trigger,
|
||||||
|
icon,
|
||||||
|
variant = "secondary",
|
||||||
|
className = "",
|
||||||
|
buttonStyle = "",
|
||||||
|
show,
|
||||||
|
onToggle,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
children
|
||||||
|
}) => {
|
||||||
|
const isControlled = show !== undefined;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const triggerRef = useRef(null);
|
||||||
|
const dropdownRef = useRef(null);
|
||||||
|
|
||||||
|
const actualOpen = isControlled ? show : open;
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
const newState = !actualOpen;
|
||||||
|
if (!isControlled) setOpen(newState);
|
||||||
|
onToggle?.(newState);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(e.target) &&
|
||||||
|
!triggerRef.current?.contains(e.target)
|
||||||
|
) {
|
||||||
|
if (!isControlled) setOpen(false);
|
||||||
|
onToggle?.(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [isControlled, onToggle]);
|
||||||
|
|
||||||
|
const triggerElement = trigger
|
||||||
|
? (typeof trigger === "function"
|
||||||
|
? trigger({ onClick: toggle, ref: triggerRef })
|
||||||
|
: cloneElement(trigger, { onClick: toggle, ref: triggerRef }))
|
||||||
|
: (
|
||||||
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
|
variant={variant}
|
||||||
|
className={`circle-btn ${buttonStyle}`}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`position-relative d-inline-block`}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
ref={triggerRef}
|
||||||
|
>
|
||||||
|
{triggerElement}
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{actualOpen && (
|
||||||
|
<_motion.div
|
||||||
|
ref={dropdownRef}
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className={dropdownClasses}
|
||||||
|
>
|
||||||
|
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
|
||||||
|
</_motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnimatedDropdown;
|
||||||
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;
|
||||||
@@ -1,47 +1,28 @@
|
|||||||
import { useConfig } from "../contexts/ConfigContext.jsx";
|
import { Route, Routes } from 'react-router-dom'
|
||||||
import Card from "./Card.jsx";
|
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||||
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import Header from "@/components/Header.jsx";
|
||||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
import Home from "@/pages/Home.jsx";
|
||||||
import Footer from "./Footer.jsx";
|
import Login from "@/pages/Login.jsx";
|
||||||
import Header from "./Header.jsx";
|
import Accounts from "@/pages/Accounts.jsx";
|
||||||
import ContentWrapper from "./ContentWrapper.jsx";
|
import ProtectedRoute from '@/components/Auth/ProtectedRoute';
|
||||||
|
import Register from '@/pages/Register.jsx';
|
||||||
function App() {
|
|
||||||
const { config, loading, error } = useConfig();
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className={"text-center py-5"}>
|
|
||||||
<FontAwesomeIcon icon={faSpinner} size={"6x"} spin={true} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className={"text-center py-5"}>
|
|
||||||
<h1>Error</h1>
|
|
||||||
<p>{error.message}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
<ContentWrapper>
|
<Routes>
|
||||||
<div className={"row g-4"}>
|
<Route path="/" element={<Home />} />
|
||||||
{config.map((card, index) => (
|
<Route path="/login" element={<Login />} />
|
||||||
<Card
|
<Route path="/register" element={<Register />} />
|
||||||
key={index}
|
<Route path="/accounts" element={
|
||||||
{...card}
|
<ProtectedRoute>
|
||||||
/>
|
<Accounts />
|
||||||
))}
|
</ProtectedRoute>
|
||||||
</div>
|
} />
|
||||||
</ContentWrapper>
|
</Routes>
|
||||||
<Footer />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { identity, authStatus } = useAuth();
|
||||||
|
|
||||||
|
if (authStatus !== "authenticated") return null;
|
||||||
|
|
||||||
|
const userRole = identity?.metadata?.role;
|
||||||
|
|
||||||
|
return roles.includes(userRole) ? children : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IfRole;
|
||||||
121
src/components/Auth/LoginForm.jsx
Normal file
121
src/components/Auth/LoginForm.jsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faUser } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { Form, Button, Alert, FloatingLabel} from 'react-bootstrap';
|
||||||
|
import PasswordInput from './PasswordInput.jsx';
|
||||||
|
|
||||||
|
import { useContext, useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { AuthContext } from "@/context/AuthContext.jsx";
|
||||||
|
|
||||||
|
import CustomContainer from '@/components/CustomContainer.jsx';
|
||||||
|
import ContentWrapper from '@/components/ContentWrapper.jsx';
|
||||||
|
import { random } from '@/util/array.js';
|
||||||
|
|
||||||
|
import '@/css/LoginForm.css';
|
||||||
|
|
||||||
|
const LoginForm = () => {
|
||||||
|
const PHRASES = ["U got the wrong house fool!", "¿Te conozco?", "Hola :3", "¿Quién chota sos?🧐", "Identifícate", "Arto ahí ¿quién ere?"];
|
||||||
|
|
||||||
|
const { login, error } = useContext(AuthContext);
|
||||||
|
const [randomPhrase, setRandomPhrase] = useState("");
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [formState, setFormState] = useState({
|
||||||
|
username: "",
|
||||||
|
password: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRandomPhrase(random(PHRASES));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormState((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const loginBody = {
|
||||||
|
username: formState.username,
|
||||||
|
password: formState.password,
|
||||||
|
serviceId: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(loginBody);
|
||||||
|
navigate("/");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error de login:", err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomContainer>
|
||||||
|
<ContentWrapper>
|
||||||
|
<div className="login-card card p-5 mx-auto col-12 col-md-8 col-lg-6 col-xl-5 d-flex flex-column gap-4">
|
||||||
|
<h1 className="text-center">{randomPhrase}</h1>
|
||||||
|
<Form className="d-flex flex-column gap-5" onSubmit={handleSubmit}>
|
||||||
|
<div className="d-flex flex-column gap-3">
|
||||||
|
<FloatingLabel
|
||||||
|
controlId="floatingUsuario"
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faUser} className="me-2" />
|
||||||
|
Usuario
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
placeholder=""
|
||||||
|
name="username"
|
||||||
|
value={formState.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="themed-input rounded-0"
|
||||||
|
/>
|
||||||
|
</FloatingLabel>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
value={formState.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
name="password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/*<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center gap-2">
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
name="keepLoggedIn"
|
||||||
|
label="Mantener sesión iniciada"
|
||||||
|
className="text-secondary"
|
||||||
|
value={formState.keepLoggedIn}
|
||||||
|
onChange={(e) => { formState.keepLoggedIn = e.target.checked; setFormState({ ...formState }) }}
|
||||||
|
/>
|
||||||
|
<Link disabled to="#" className="muted">
|
||||||
|
Olvidé mi contraseña
|
||||||
|
</Link>
|
||||||
|
</div>*/}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" className="text-center py-2 mb-0">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Button type="submit" className="w-75 padding-4 rounded-0 border-0 shadow-sm login-button">
|
||||||
|
Iniciar sesión
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</ContentWrapper>
|
||||||
|
</CustomContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default LoginForm;
|
||||||
56
src/components/Auth/PasswordInput.jsx
Normal file
56
src/components/Auth/PasswordInput.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Form, FloatingLabel, Button } from 'react-bootstrap';
|
||||||
|
import '@/css/PasswordInput.css';
|
||||||
|
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faEye, faEyeSlash, faKey } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
const PasswordInput = ({ value, onChange, name = "password" }) => {
|
||||||
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
|
const toggleShow = () => setShow(prev => !prev);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="position-relative w-100">
|
||||||
|
<FloatingLabel
|
||||||
|
controlId="passwordInput"
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faKey} className="me-2" />
|
||||||
|
Contraseña
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.Control
|
||||||
|
type={show ? "text" : "password"}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
placeholder=""
|
||||||
|
onChange={onChange}
|
||||||
|
className="themed-input rounded-0 pe-5"
|
||||||
|
/>
|
||||||
|
</FloatingLabel>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="show-button position-absolute end-0 top-50 translate-middle-y me-2"
|
||||||
|
onClick={toggleShow}
|
||||||
|
aria-label="Mostrar contraseña"
|
||||||
|
tabIndex={-1}
|
||||||
|
style={{ zIndex: 2 }}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={show ? faEyeSlash : faEye} className='fa-lg' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
PasswordInput.propTypes = {
|
||||||
|
value: PropTypes.any,
|
||||||
|
onChange: PropTypes.func,
|
||||||
|
name: PropTypes.string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PasswordInput;
|
||||||
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} />;
|
||||||
|
if (authStatus === "unauthenticated") return <Navigate to="/login" replace />;
|
||||||
|
if (authStatus === "authenticated" && minimumRoles) {
|
||||||
|
const userRole = JSON.parse(localStorage.getItem("identity"))?.metadata?.role;
|
||||||
|
if (!minimumRoles.includes(userRole)) return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProtectedRoute;
|
||||||
113
src/components/Auth/RegisterForm.jsx
Normal file
113
src/components/Auth/RegisterForm.jsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faUser } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { Form, Button, Alert, FloatingLabel} from 'react-bootstrap';
|
||||||
|
import PasswordInput from './PasswordInput.jsx';
|
||||||
|
|
||||||
|
import { useContext, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { AuthContext } from "@/context/AuthContext.jsx";
|
||||||
|
|
||||||
|
import CustomContainer from '@/components/CustomContainer.jsx';
|
||||||
|
import ContentWrapper from '@/components/ContentWrapper.jsx';
|
||||||
|
|
||||||
|
import '@/css/LoginForm.css';
|
||||||
|
|
||||||
|
const RegisterForm = () => {
|
||||||
|
const { register, error } = useContext(AuthContext);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [formState, setFormState] = useState({
|
||||||
|
username: "",
|
||||||
|
password: ""
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormState((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const registerBody = {
|
||||||
|
username: formState.username,
|
||||||
|
password: formState.password,
|
||||||
|
serviceId: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await register(registerBody);
|
||||||
|
navigate("/");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error de registro:", err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomContainer>
|
||||||
|
<ContentWrapper>
|
||||||
|
<div className="login-card card p-5 mx-auto col-12 col-md-8 col-lg-6 col-xl-5 d-flex flex-column gap-4">
|
||||||
|
<h1 className="text-center">Centro de cuentas</h1>
|
||||||
|
<Form className="d-flex flex-column gap-5" onSubmit={handleSubmit}>
|
||||||
|
<div className="d-flex flex-column gap-3">
|
||||||
|
<FloatingLabel
|
||||||
|
controlId="floatingUsuario"
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={faUser} className="me-2" />
|
||||||
|
Usuario
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.Control
|
||||||
|
type="text"
|
||||||
|
placeholder=""
|
||||||
|
name="username"
|
||||||
|
value={formState.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="themed-input rounded-0"
|
||||||
|
/>
|
||||||
|
</FloatingLabel>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
value={formState.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
name="password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/*<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center gap-2">
|
||||||
|
<Form.Check
|
||||||
|
type="checkbox"
|
||||||
|
name="keepLoggedIn"
|
||||||
|
label="Mantener sesión iniciada"
|
||||||
|
className="text-secondary"
|
||||||
|
value={formState.keepLoggedIn}
|
||||||
|
onChange={(e) => { formState.keepLoggedIn = e.target.checked; setFormState({ ...formState }) }}
|
||||||
|
/>
|
||||||
|
<Link disabled to="#" className="muted">
|
||||||
|
Olvidé mi contraseña
|
||||||
|
</Link>
|
||||||
|
</div>*/}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="danger" className="text-center py-2 mb-0">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Button type="submit" className="w-75 padding-4 rounded-0 border-0 shadow-sm login-button">
|
||||||
|
Registrarse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<span className="text-center">*Desde aquí podrás manejar todas tus cuentas</span>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</ContentWrapper>
|
||||||
|
</CustomContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default RegisterForm;
|
||||||
@@ -2,14 +2,15 @@ import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
|||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import "@/css/Card.css"
|
||||||
|
|
||||||
export default function Card({ title, link }) {
|
const Card = ({ title, link }) => {
|
||||||
const [image, setImage] = useState("");
|
const [image, setImage] = useState("");
|
||||||
const [buttonContent, setButtonContent] = useState(<>{title}</>);
|
const [buttonContent, setButtonContent] = useState(<>{title}</>);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getImage = async () => {
|
const getImage = async () => {
|
||||||
const response = await fetch("https://api.miarma.net/v1/screenshot?url=" + link);
|
const response = await fetch("https://api.miarma.net/v1/screenshoter?url=" + link);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const imageURL = URL.createObjectURL(blob);
|
const imageURL = URL.createObjectURL(blob);
|
||||||
setImage(imageURL);
|
setImage(imageURL);
|
||||||
@@ -53,3 +54,5 @@ Card.propTypes = {
|
|||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
link: PropTypes.string.isRequired
|
link: PropTypes.string.isRequired
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Card;
|
||||||
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="md" centered>
|
||||||
|
<Modal.Header className='justify-content-between rounded-top-4'>
|
||||||
|
<Modal.Title>{title}</Modal.Title>
|
||||||
|
<Button variant='transparent' onClick={onClose}>
|
||||||
|
<FontAwesomeIcon icon={faXmark} className='close-button fa-xl' />
|
||||||
|
</Button>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body className="rounded-bottom-4 p-0"
|
||||||
|
style={{
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: '1rem',
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomModal;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import License from './License';
|
import License from '@/components/License';
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,88 @@
|
|||||||
|
import { Button } from "react-bootstrap";
|
||||||
|
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import IfNotAuthenticated from "./Auth/IfNotAuthenticated";
|
||||||
|
import IfAuthenticated from "./Auth/IfAuthenticated";
|
||||||
|
import Avatar from "boring-avatars";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faSignIn, faSignOut, faUserPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import AnimatedDropdown from "./AnimatedDropdown";
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { identity, logout } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex justify-content-center my-3">
|
<header className="position-relative my-3">
|
||||||
<img src="/images/logo-with-text.svg" width={192} height={192} />
|
<div className="d-flex justify-content-center">
|
||||||
</div>
|
<Link to={"/"}>
|
||||||
|
<img src="/images/logo-with-text.svg" width={192} height={192} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="position-absolute top-0 end-0 me-3 d-flex align-items-center gap-2">
|
||||||
|
|
||||||
|
<IfAuthenticated>
|
||||||
|
<AnimatedDropdown
|
||||||
|
trigger={
|
||||||
|
<div className="d-flex align-items-center gap-2 p-1 cursor-pointer">
|
||||||
|
<Avatar name={identity?.user.displayName} size={32} />
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="fw-bold text-truncate d-none d-md-inline"
|
||||||
|
style={{ maxWidth: "120px" }}
|
||||||
|
>
|
||||||
|
@{identity?.account.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="end-0"
|
||||||
|
>
|
||||||
|
{({ closeDropdown }) => (
|
||||||
|
<>
|
||||||
|
<Link to="/accounts" className="dropdown-item">
|
||||||
|
Mi cuenta
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="dropdown-item text-danger cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
closeDropdown();
|
||||||
|
logout();
|
||||||
|
navigate("/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faSignOut} className="me-2" />
|
||||||
|
Cerrar sesión
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatedDropdown>
|
||||||
|
</IfAuthenticated>
|
||||||
|
|
||||||
|
<IfNotAuthenticated>
|
||||||
|
{!location.pathname.includes("login") && (
|
||||||
|
<Link to="/login">
|
||||||
|
<Button variant="primary" size="sm" className="rounded-0 px-3 py-1 d-flex align-items-center">
|
||||||
|
<FontAwesomeIcon icon={faSignIn} className="me-2" />
|
||||||
|
<span className="d-none d-md-inline">Iniciar sesión</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!location.pathname.includes("register") && (
|
||||||
|
<Link to="/register">
|
||||||
|
<Button variant="primary" size="sm" className="rounded-0 px-3 py-1 d-flex align-items-center">
|
||||||
|
<FontAwesomeIcon icon={faUserPlus} className="me-2" />
|
||||||
|
<span className="d-none d-md-inline">Registrarse</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</IfNotAuthenticated>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
src/components/List.jsx
Normal file
15
src/components/List.jsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import ListItem from "./ListItem";
|
||||||
|
import {ListGroup} from 'react-bootstrap';
|
||||||
|
import '../css/List.css';
|
||||||
|
|
||||||
|
const List = ({ datos, config }) => {
|
||||||
|
return (
|
||||||
|
<ListGroup className="gap-2">
|
||||||
|
{datos.map((item, index) => (
|
||||||
|
<ListItem key={index} item={item} config={config} index={index} />
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default List;
|
||||||
57
src/components/ListItem.jsx
Normal file
57
src/components/ListItem.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { motion as _motion } from "framer-motion";
|
||||||
|
import { ListGroup } from "react-bootstrap";
|
||||||
|
import '../css/ListItem.css';
|
||||||
|
|
||||||
|
const MotionListGroupItem = _motion.create(ListGroup.Item);
|
||||||
|
|
||||||
|
const ListItem = ({ item, config, index }) => {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
numericField,
|
||||||
|
pfp,
|
||||||
|
showIndex,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionListGroupItem
|
||||||
|
className="custom-list-item d-flex justify-content-between rounded-4 align-items-center"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center gap-3">
|
||||||
|
{showIndex && (
|
||||||
|
<div className="list-item-index">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pfp && item[pfp] && (
|
||||||
|
<img
|
||||||
|
src={item[pfp]}
|
||||||
|
alt="pfp"
|
||||||
|
className="list-item-avatar"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="d-flex flex-column">
|
||||||
|
{title && item[title] && (
|
||||||
|
<h5 className="fw-bold m-0">{item[title]}</h5>
|
||||||
|
)}
|
||||||
|
{subtitle && item[subtitle] && (
|
||||||
|
<div className="subtitle m-0">{item[subtitle]}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{numericField && item[numericField] !== undefined && (
|
||||||
|
<span className="badge bg-primary rounded-pill">
|
||||||
|
{item[numericField]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</MotionListGroupItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListItem;
|
||||||
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;
|
||||||
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;
|
||||||
32
src/components/PaginatedCardGrid.jsx
Normal file
32
src/components/PaginatedCardGrid.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import LoadingIcon from '@/components/LoadingIcon.jsx';
|
||||||
|
import "@/css/PaginatedCardGrid.css"
|
||||||
|
|
||||||
|
const PaginatedCardGrid = ({
|
||||||
|
items = [],
|
||||||
|
renderCard,
|
||||||
|
loaderRef,
|
||||||
|
loading = false
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="cards-grid">
|
||||||
|
|
||||||
|
{items.map((item, i) => renderCard(item, i))}
|
||||||
|
|
||||||
|
<div ref={loaderRef} className="loading-trigger d-flex justify-content-center align-items-center">
|
||||||
|
{loading && <LoadingIcon />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
PaginatedCardGrid.propTypes = {
|
||||||
|
items: PropTypes.array,
|
||||||
|
renderCard: PropTypes.func.isRequired,
|
||||||
|
creatingItem: PropTypes.any,
|
||||||
|
renderCreatingCard: PropTypes.func,
|
||||||
|
loaderRef: PropTypes.object,
|
||||||
|
loading: PropTypes.bool
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PaginatedCardGrid;
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
src/context/AuthContext.jsx
Normal file
168
src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { useState, useEffect, createContext } from "react";
|
||||||
|
import createAxiosInstance from "@/api/axiosInstance";
|
||||||
|
import { useConfig } from "@/hooks/useConfig";
|
||||||
|
|
||||||
|
export const AuthContext = createContext();
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }) => {
|
||||||
|
const axios = createAxiosInstance();
|
||||||
|
const { config } = useConfig();
|
||||||
|
|
||||||
|
const [token, setToken] = useState(() => localStorage.getItem("token"));
|
||||||
|
const [identity, setIdentity] = useState(() => {
|
||||||
|
const stored = localStorage.getItem("identity");
|
||||||
|
return stored ? JSON.parse(stored) : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [authStatus, setAuthStatus] = useState("checking");
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
setAuthStatus("unauthenticated");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URL = config.apiConfig.coreUrl;
|
||||||
|
const VALIDATE_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.validateToken}`;
|
||||||
|
|
||||||
|
const checkAuth = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(VALIDATE_URL, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 200) {
|
||||||
|
setAuthStatus("authenticated");
|
||||||
|
} else {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error validando token:", err);
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
}, [token, config]);
|
||||||
|
|
||||||
|
const login = async (formData) => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const BASE_URL = config.apiConfig.baseUrl;
|
||||||
|
const LOGIN_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.login}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post(LOGIN_URL, formData);
|
||||||
|
|
||||||
|
const { token, user, account, metadata } = res.data;
|
||||||
|
|
||||||
|
const identity = {
|
||||||
|
user,
|
||||||
|
account,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem("token", token);
|
||||||
|
localStorage.setItem("identity", JSON.stringify(identity));
|
||||||
|
|
||||||
|
setToken(token);
|
||||||
|
setIdentity(identity);
|
||||||
|
setAuthStatus("authenticated");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error al iniciar sesión:", err);
|
||||||
|
|
||||||
|
let message = "Ha ocurrido un error inesperado.";
|
||||||
|
|
||||||
|
if (err.response) {
|
||||||
|
const { status, data } = err.response;
|
||||||
|
|
||||||
|
if (status === 400) {
|
||||||
|
message = "Usuario o contraseña incorrectos.";
|
||||||
|
} else if (status === 403) {
|
||||||
|
message = "Tu cuenta está inactiva o suspendida.";
|
||||||
|
} else if (status === 404) {
|
||||||
|
message = "Usuario no encontrado.";
|
||||||
|
} else if (data?.message) {
|
||||||
|
message = data.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (formData) => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const BASE_URL = config.apiConfig.baseUrl;
|
||||||
|
const REGISTER_URL = `${BASE_URL}${config.apiConfig.endpoints.auth.register}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post(REGISTER_URL, formData);
|
||||||
|
|
||||||
|
const { token, user, account, metadata } = res.data;
|
||||||
|
|
||||||
|
const identity = {
|
||||||
|
user,
|
||||||
|
account,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem("token", token);
|
||||||
|
localStorage.setItem("identity", JSON.stringify(identity));
|
||||||
|
|
||||||
|
setToken(token);
|
||||||
|
setIdentity(identity);
|
||||||
|
setAuthStatus("authenticated");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error al registrarse:", err);
|
||||||
|
|
||||||
|
let message = "Ha ocurrido un error inesperado.";
|
||||||
|
|
||||||
|
if (err.response) {
|
||||||
|
const { status, data } = err.response;
|
||||||
|
|
||||||
|
if (status === 400) {
|
||||||
|
message = "Usuario o contraseña incorrectos.";
|
||||||
|
} else if (status === 403) {
|
||||||
|
message = "Tu cuenta está inactiva o suspendida.";
|
||||||
|
} else if (status === 404) {
|
||||||
|
message = "Usuario no encontrado.";
|
||||||
|
} else if (data?.message) {
|
||||||
|
message = data.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(message);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
localStorage.removeItem("identity");
|
||||||
|
setIdentity(null);
|
||||||
|
setToken(null);
|
||||||
|
setAuthStatus("unauthenticated");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
identity, // { user, account, metadata }
|
||||||
|
token,
|
||||||
|
authStatus,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
error,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
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, onError, children }) => {
|
||||||
|
const data = useData(config, onError);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataContext.Provider value={data}>
|
||||||
|
{children}
|
||||||
|
</DataContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
DataProvider.propTypes = {
|
||||||
|
config: PropTypes.shape({
|
||||||
|
baseUrl: PropTypes.string.isRequired,
|
||||||
|
params: PropTypes.object,
|
||||||
|
}).isRequired,
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
};
|
||||||
40
src/context/ErrorContext.jsx
Normal file
40
src/context/ErrorContext.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { createContext, useState, useContext } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import NotificationModal from '@/components/NotificationModal';
|
||||||
|
|
||||||
|
const ErrorContext = createContext();
|
||||||
|
|
||||||
|
export const ErrorProvider = ({ children }) => {
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const showError = (err) => {
|
||||||
|
setError({
|
||||||
|
title: err.status ? `Error ${err.status}` : "Error",
|
||||||
|
message: err.message,
|
||||||
|
variant: 'danger'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeError = () => setError(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorContext.Provider value={{ showError }}>
|
||||||
|
{children}
|
||||||
|
{error && (
|
||||||
|
<NotificationModal
|
||||||
|
show={true}
|
||||||
|
onClose={closeError}
|
||||||
|
title={error.title}
|
||||||
|
message={error.message}
|
||||||
|
variant='danger'
|
||||||
|
buttons={[{ label: "Aceptar", variant: "danger", onClick: closeError }]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ErrorContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
ErrorProvider.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useError = () => useContext(ErrorContext);
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { createContext, useContext, useState, useEffect } from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ConfigContext.jsx
|
|
||||||
*
|
|
||||||
* Este archivo define el contexto de configuración para la aplicación, permitiendo cargar y manejar la configuración desde un archivo externo.
|
|
||||||
*
|
|
||||||
* Importaciones:
|
|
||||||
* - createContext, useContext, useState, useEffect: Funciones de React para crear y utilizar contextos, manejar estados y efectos secundarios.
|
|
||||||
* - PropTypes: Librería para la validación de tipos de propiedades en componentes de React.
|
|
||||||
*
|
|
||||||
* Funcionalidad:
|
|
||||||
* - ConfigContext: Contexto que almacena la configuración cargada, el estado de carga y cualquier error ocurrido durante la carga de la configuración.
|
|
||||||
* - ConfigProvider: Proveedor de contexto que maneja la carga de la configuración y proporciona el estado de la configuración a los componentes hijos.
|
|
||||||
* - Utiliza `fetch` para cargar la configuración desde un archivo JSON.
|
|
||||||
* - Maneja el estado de carga y errores durante la carga de la configuración.
|
|
||||||
* - useConfig: Hook personalizado para acceder al contexto de configuración.
|
|
||||||
*
|
|
||||||
* PropTypes:
|
|
||||||
* - ConfigProvider espera un único hijo (`children`) que es requerido y debe ser un nodo de React.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
const ConfigContext = createContext();
|
|
||||||
|
|
||||||
export const ConfigProvider = ({ children }) => {
|
|
||||||
const [config, setConfig] = useState([]);
|
|
||||||
const [configLoading, setLoading] = useState(true);
|
|
||||||
const [configError, setError] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchConfig = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/config/settings.json");
|
|
||||||
if (!response.ok) throw new Error("Error al cargar settings.json");
|
|
||||||
const json = await response.json();
|
|
||||||
setConfig(json);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchConfig();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConfigContext.Provider value={{ config, configLoading, configError }}>
|
|
||||||
{children}
|
|
||||||
</ConfigContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
ConfigProvider.propTypes = {
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useConfig = () => useContext(ConfigContext);
|
|
||||||
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;
|
||||||
|
}
|
||||||
32
src/css/LoginForm.css
Normal file
32
src/css/LoginForm.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* ================================
|
||||||
|
LOGIN - CARD CONTAINER (VISUAL)
|
||||||
|
================================== */
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background-color: var(--login-bg) !important;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
INPUTS VISUALES
|
||||||
|
================================== */
|
||||||
|
|
||||||
|
input.form-control {
|
||||||
|
background-color: var(--input-bg);
|
||||||
|
color: var(--input-text);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
LABELS PERSONALIZADAS
|
||||||
|
================================== */
|
||||||
|
|
||||||
|
.form-floating>label {
|
||||||
|
font-family: 'Product Sans';
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: var(--label-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-floating>label::after {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
16
src/css/PaginatedCardGrid.css
Normal file
16
src/css/PaginatedCardGrid.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.cards-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cards-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-trigger {
|
||||||
|
margin-top: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
/* Colores base */
|
||||||
--light: #f6f6f6;
|
--light: #f6f6f6;
|
||||||
--white: #ffffff;
|
--white: #ffffff;
|
||||||
--blue: #2F6CA3;
|
--blue: #2F6CA3;
|
||||||
@@ -37,6 +38,31 @@
|
|||||||
--text-dark: #212529;
|
--text-dark: #212529;
|
||||||
--text-light: #ffffff;
|
--text-light: #ffffff;
|
||||||
--bg: #efefef;
|
--bg: #efefef;
|
||||||
|
|
||||||
|
/* Login / Inputs */
|
||||||
|
--login-bg: var(--white);
|
||||||
|
--text-color: var(--text-dark);
|
||||||
|
--input-bg: var(--light);
|
||||||
|
--input-text: var(--text-dark);
|
||||||
|
--input-border: var(--muted);
|
||||||
|
--label-color: var(--muted);
|
||||||
|
--placeholder-color: var(--muted);
|
||||||
|
--input-focus-shadow: rgba(47, 108, 163, 0.25);
|
||||||
|
|
||||||
|
/* Botones */
|
||||||
|
--show-btn-color: var(--blue);
|
||||||
|
--show-btn-hover: var(--blue-dark);
|
||||||
|
--accent-color: var(--blue);
|
||||||
|
|
||||||
|
/* Dropdown / Navbar */
|
||||||
|
--navbar-bg: var(--white);
|
||||||
|
--navbar-dropdown-item-color: var(--text-dark);
|
||||||
|
--divider-color: #dee2e6;
|
||||||
|
--shadow-color: rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
/* Estados / Extra */
|
||||||
|
--secondary-color: var(--blue-dark);
|
||||||
|
--muted-color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -87,3 +113,58 @@ footer {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--modal-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.themed-input {
|
||||||
|
background-color: var(--input-bg) !important;
|
||||||
|
color: var(--input-text) !important;
|
||||||
|
border: 1px solid var(--input-border) !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.themed-input:focus {
|
||||||
|
border-color: var(--accent-color) !important;
|
||||||
|
box-shadow: 0 0 0 0.25rem var(--input-focus-shadow);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.themed-input::placeholder {
|
||||||
|
color: var(--placeholder-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
color: rgb(215, 48, 48);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout:hover {
|
||||||
|
color: rgb(162, 26, 26);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #868686;
|
||||||
|
}
|
||||||
4
src/hooks/useAuth.js
Normal file
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);
|
||||||
140
src/hooks/useData.js
Normal file
140
src/hooks/useData.js
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export const useData = (config, onError) => {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [dataLoading, setLoading] = useState(true);
|
||||||
|
const [dataError, setError] = useState(null);
|
||||||
|
const configRef = useRef();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.baseUrl) {
|
||||||
|
configRef.current = config;
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const getAuthHeaders = (isFormData = false) => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
|
||||||
|
const headers = {};
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
if (!isFormData) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAxiosError = (err) => {
|
||||||
|
if (err.response && err.response.data) {
|
||||||
|
const data = err.response.data;
|
||||||
|
|
||||||
|
if (data.status === 422 && data.errors) {
|
||||||
|
return {
|
||||||
|
status: 422,
|
||||||
|
errors: data.errors,
|
||||||
|
path: data.path ?? null,
|
||||||
|
timestamp: data.timestamp ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: data.status ?? err.response.status,
|
||||||
|
error: data.error ?? null,
|
||||||
|
message: data.message ?? err.response.statusText ?? "Error desconocido",
|
||||||
|
path: data.path ?? null,
|
||||||
|
timestamp: data.timestamp ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err.request) {
|
||||||
|
return {
|
||||||
|
status: null,
|
||||||
|
error: "Network Error",
|
||||||
|
message: "No se pudo conectar al servidor",
|
||||||
|
path: null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: null,
|
||||||
|
error: "Client Error",
|
||||||
|
message: err.message || "Error desconocido",
|
||||||
|
path: null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
const current = configRef.current;
|
||||||
|
if (!current?.baseUrl) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(current.baseUrl, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
params: current.params,
|
||||||
|
});
|
||||||
|
setData(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
const error = handleAxiosError(err);
|
||||||
|
setError(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.baseUrl) fetchData();
|
||||||
|
}, [config, fetchData]);
|
||||||
|
|
||||||
|
const requestWrapper = async (method, endpoint, payload = null, refresh = false) => {
|
||||||
|
try {
|
||||||
|
const isFormData = payload instanceof FormData;
|
||||||
|
const headers = getAuthHeaders(isFormData);
|
||||||
|
const cfg = { headers };
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (method === "get") {
|
||||||
|
if (payload) cfg.params = payload;
|
||||||
|
response = await axios.get(endpoint, cfg);
|
||||||
|
} else if (method === "delete") {
|
||||||
|
if (payload) cfg.data = payload;
|
||||||
|
response = await axios.delete(endpoint, cfg);
|
||||||
|
} else {
|
||||||
|
response = await axios[method](endpoint, payload, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refresh) await fetchData();
|
||||||
|
return response.data;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const error = handleAxiosError(err);
|
||||||
|
|
||||||
|
if (error.status !== 422 && onError) {
|
||||||
|
onError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearError = () => setError(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
dataLoading,
|
||||||
|
dataError,
|
||||||
|
clearError,
|
||||||
|
getData: (url, params, refresh = true) => requestWrapper("get", url, params, refresh),
|
||||||
|
postData: (url, body, refresh = true) => requestWrapper("post", url, body, refresh),
|
||||||
|
putData: (url, body, refresh = true) => requestWrapper("put", url, body, refresh),
|
||||||
|
deleteData: (url, refresh = true) => requestWrapper("delete", url, null, refresh),
|
||||||
|
deleteDataWithBody: (url, body, refresh = true) => requestWrapper("delete", url, body, refresh)
|
||||||
|
};
|
||||||
|
};
|
||||||
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);
|
||||||
35
src/hooks/useRequestCount.js
Normal file
35
src/hooks/useRequestCount.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useConfig } from './useConfig';
|
||||||
|
|
||||||
|
const useRequestCount = () => {
|
||||||
|
const { config } = useConfig();
|
||||||
|
const [count, setCount] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const fetchCount = async () => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(
|
||||||
|
config.apiConfig.baseUrl + config.apiConfig.endpoints.requests.count,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setCount(res.data.count);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error al obtener el número de solicitudes:', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCount();
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useRequestCount;
|
||||||
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);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [alreadyWarned, logout]);
|
||||||
|
|
||||||
|
const handleRenew = async () => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
const decoded = parseJwt(token);
|
||||||
|
const now = Date.now();
|
||||||
|
const expTime = decoded?.exp * 1000;
|
||||||
|
|
||||||
|
if (!token || !decoded || now > expTime) {
|
||||||
|
logout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(
|
||||||
|
`${config.apiConfig.coreUrl}${config.apiConfig.endpoints.auth.refreshToken}`,
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const newToken = response.data.token;
|
||||||
|
localStorage.setItem("token", newToken);
|
||||||
|
setShowModal(false);
|
||||||
|
setAlreadyWarned(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error renovando sesión:", err);
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = showModal && (
|
||||||
|
<NotificationModal
|
||||||
|
show={true}
|
||||||
|
onClose={() => {
|
||||||
|
setShowModal(false);
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
title="¿Quieres seguir conectado?"
|
||||||
|
message="Tu sesión está a punto de expirar. ¿Quieres renovarla 1 hora más?"
|
||||||
|
variant="info"
|
||||||
|
buttons={[
|
||||||
|
{
|
||||||
|
label: "Renovar sesión",
|
||||||
|
variant: "success",
|
||||||
|
onClick: handleRenew,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Cerrar sesión",
|
||||||
|
variant: "danger",
|
||||||
|
onClick: () => {
|
||||||
|
logout();
|
||||||
|
setShowModal(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { modal };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSessionRenewal;
|
||||||
10
src/hooks/useTheme.js
Normal file
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;
|
||||||
|
};
|
||||||
21
src/main.jsx
21
src/main.jsx
@@ -1,14 +1,25 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import App from './components/App.jsx'
|
import App from '@/components/App.jsx'
|
||||||
import { ConfigProvider } from './contexts/ConfigContext.jsx'
|
import { ConfigProvider } from '@/context/ConfigContext.jsx'
|
||||||
import './css/index.css';
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import './css/Card.css';
|
import { ThemeProvider } from '@/context/ThemeContext'
|
||||||
|
import { AuthProvider } from '@/context/AuthContext'
|
||||||
|
import { ErrorProvider } from '@/context/ErrorContext.jsx'
|
||||||
|
import '@/css/index.css';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<App />
|
<ThemeProvider>
|
||||||
|
<ErrorProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</ErrorProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
136
src/pages/Accounts.jsx
Normal file
136
src/pages/Accounts.jsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
// Accounts.jsx
|
||||||
|
import { DataProvider } from "@/context/DataContext";
|
||||||
|
import { useError } from "@/context/ErrorContext";
|
||||||
|
import LoadingIcon from "@/components/LoadingIcon";
|
||||||
|
import { useDataContext } from "@/hooks/useDataContext";
|
||||||
|
import CustomContainer from "@/components/CustomContainer";
|
||||||
|
import ContentWrapper from "@/components/ContentWrapper";
|
||||||
|
import { useConfig } from "@/hooks/useConfig";
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import PaginatedCardGrid from "@/components/PaginatedCardGrid";
|
||||||
|
import AccountCard from "@/components/Accounts/AccountCard";
|
||||||
|
import CustomModal from "@/components/CustomModal";
|
||||||
|
import { Button } from "react-bootstrap";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const Accounts = () => {
|
||||||
|
const { config, configLoading } = useConfig();
|
||||||
|
const { showError } = useError();
|
||||||
|
|
||||||
|
if (configLoading) return <p><LoadingIcon /></p>;
|
||||||
|
|
||||||
|
const identity = JSON.parse(localStorage.getItem("identity"));
|
||||||
|
|
||||||
|
const BASE_URL = `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.credentials.byUserId}`
|
||||||
|
.replace(":userId", identity?.user.userId);
|
||||||
|
|
||||||
|
const BY_ID_URL = `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.credentials.byId}`;
|
||||||
|
|
||||||
|
const reqConfig = {
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
byIdUrl: BY_ID_URL,
|
||||||
|
params: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataProvider config={reqConfig} onError={showError}>
|
||||||
|
<AccountsContent reqConfig={reqConfig} />
|
||||||
|
</DataProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccountsContent = ({ reqConfig }) => {
|
||||||
|
const { data, dataLoading, putData } = useDataContext();
|
||||||
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||||
|
const [pendingAccountId, setPendingAccountId] = useState(null);
|
||||||
|
const [pendingStatus, setPendingStatus] = useState(null);
|
||||||
|
const [confirmedStatusChange, setConfirmedStatusChange] = useState(null);
|
||||||
|
|
||||||
|
const handleRequestStatusChange = (identity, newStatus) => {
|
||||||
|
if (newStatus === 0 && identity?.status !== 0) {
|
||||||
|
setPendingAccountId(identity?.credentialId);
|
||||||
|
setPendingStatus(newStatus);
|
||||||
|
setShowConfirmModal(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmDeactivation = () => {
|
||||||
|
setConfirmedStatusChange({
|
||||||
|
credentialId: pendingAccountId,
|
||||||
|
status: pendingStatus
|
||||||
|
});
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setPendingAccountId(null);
|
||||||
|
setPendingStatus(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (updatedIdentity) => {
|
||||||
|
try {
|
||||||
|
await putData(
|
||||||
|
reqConfig.byIdUrl.replace(":credentialId", updatedIdentity?.credentialId),
|
||||||
|
{
|
||||||
|
username: updatedIdentity?.username,
|
||||||
|
email: updatedIdentity?.email,
|
||||||
|
status: updatedIdentity?.status,
|
||||||
|
serviceId: updatedIdentity?.serviceId
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dataLoading) return <p><LoadingIcon /></p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomContainer>
|
||||||
|
<ContentWrapper>
|
||||||
|
<PaginatedCardGrid
|
||||||
|
items={data}
|
||||||
|
renderCard={(identity, idx) => (
|
||||||
|
<AccountCard
|
||||||
|
key={idx}
|
||||||
|
identity={identity}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onRequestStatusChange={handleRequestStatusChange}
|
||||||
|
confirmedStatusChange={confirmedStatusChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</ContentWrapper>
|
||||||
|
|
||||||
|
{showConfirmModal && (
|
||||||
|
<CustomModal
|
||||||
|
show={showConfirmModal}
|
||||||
|
onClose={() => setShowConfirmModal(false)}
|
||||||
|
title="Confirmar desactivación"
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
<p>¿Seguro que quieres pasar esta cuenta a estado inactivo? Después de desactivarla sólo podra ser reactivada o editada otra vez por un administador.</p>
|
||||||
|
<div className="d-flex justify-content-end gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowConfirmModal(false)}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleConfirmDeactivation}
|
||||||
|
>
|
||||||
|
Confirmar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CustomModal>
|
||||||
|
)}
|
||||||
|
</CustomContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountsContent.propTypes = {
|
||||||
|
reqConfig: PropTypes.object.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Accounts;
|
||||||
39
src/pages/Home.jsx
Normal file
39
src/pages/Home.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import ContentWrapper from "@/components/ContentWrapper.jsx";
|
||||||
|
import Card from "@/components/Card";
|
||||||
|
import { useConfig } from "@/hooks/useConfig";
|
||||||
|
import LoadingIcon from "@/components/LoadingIcon";
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import CustomContainer from "@/components/CustomContainer";
|
||||||
|
|
||||||
|
const Home = () => {
|
||||||
|
const { config, configLoading } = useConfig();
|
||||||
|
|
||||||
|
if (configLoading) return <p className="text-center my-5"><LoadingIcon /></p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HomeContent cards={config.pages} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const HomeContent = ({ cards }) => {
|
||||||
|
return (
|
||||||
|
<CustomContainer>
|
||||||
|
<ContentWrapper>
|
||||||
|
<div className={"row g-4"}>
|
||||||
|
{cards.map((card, index) => (
|
||||||
|
<Card
|
||||||
|
key={index}
|
||||||
|
{...card}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ContentWrapper>
|
||||||
|
</CustomContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HomeContent.propTypes = {
|
||||||
|
cards: PropTypes.array.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
||||||
21
src/pages/Login.jsx
Normal file
21
src/pages/Login.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import LoginForm from "@/components/Auth/LoginForm";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
const { authStatus } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authStatus === "authenticated") {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
}, [authStatus, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoginForm />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login;
|
||||||
17
src/pages/Register.jsx
Normal file
17
src/pages/Register.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import RegisterForm from "@/components/Auth/RegisterForm";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
const Register = () => {
|
||||||
|
const { authStatus } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
if (authStatus == "authenticated")
|
||||||
|
navigate("/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RegisterForm />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Register;
|
||||||
5
src/util/array.js
Normal file
5
src/util/array.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const random = (arr) => {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
export { random }
|
||||||
11
src/util/constants.js
Normal file
11
src/util/constants.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CONSTANTS = {
|
||||||
|
CORE_ID: 0,
|
||||||
|
HUERTOS_ID: 1,
|
||||||
|
MINECRAFT_ID: 2,
|
||||||
|
CINE_ID: 3,
|
||||||
|
MPASTE_ID: 4
|
||||||
|
};
|
||||||
|
|
||||||
|
export { CONSTANTS };
|
||||||
@@ -7,5 +7,14 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@/': '/src/',
|
||||||
|
},
|
||||||
|
},
|
||||||
publicDir: 'public',
|
publicDir: 'public',
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user