diff --git a/jsconfig.json b/jsconfig.json
new file mode 100644
index 0000000..30e99a0
--- /dev/null
+++ b/jsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src"]
+ }
\ No newline at end of file
diff --git a/package.json b/package.json
index e0d4772..1759680 100644
--- a/package.json
+++ b/package.json
@@ -15,13 +15,16 @@
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
+ "axios": "^1.13.4",
"bootstrap": "^5.3.3",
+ "boring-avatars": "^2.0.4",
+ "dayjs": "^1.11.19",
"framer-motion": "^12.11.0",
"html2canvas": "^1.4.1",
"react": "^18.3.1",
"react-bootstrap": "^2.10.10",
"react-dom": "^18.3.1",
- "react-router-dom": "^7.9.6"
+ "react-router-dom": "^7.13.0"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
diff --git a/public/config/settings.dev.json b/public/config/settings.dev.json
new file mode 100644
index 0000000..df807a3
--- /dev/null
+++ b/public/config/settings.dev.json
@@ -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"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/public/config/settings.json b/public/config/settings.json
deleted file mode 100644
index 7e704fd..0000000
--- a/public/config/settings.json
+++ /dev/null
@@ -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"
- }
-]
diff --git a/public/config/settings.prod.json b/public/config/settings.prod.json
new file mode 100644
index 0000000..49ce1e0
--- /dev/null
+++ b/public/config/settings.prod.json
@@ -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"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/api/axiosInstance.js b/src/api/axiosInstance.js
new file mode 100644
index 0000000..5a4f265
--- /dev/null
+++ b/src/api/axiosInstance.js
@@ -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;
diff --git a/src/components/Accounts/AccountCard.jsx b/src/components/Accounts/AccountCard.jsx
new file mode 100644
index 0000000..3c2f9c2
--- /dev/null
+++ b/src/components/Accounts/AccountCard.jsx
@@ -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 (
+
+
+
+
+ {getServiceName(identity.serviceId)}
+ {identity.status == 0 && (
+
+ (Se eliminará en 2 meses tras desactivación)
+
+ )}
+
+ {identity.credentialId}
+
+
+ {identity.status != 0 && (
+ !editMode ? (
+ setEditMode(true)} />
+ ) : (
+ <>
+
+
+ >
+ )
+ )}
+
+
+
+
+
+
Usuario
+ {editMode ? (
+
handleChange("username", e.target.value)} />
+ ) :
{identity.username}
}
+
+
+
+
Email
+ {editMode ? (
+
handleChange("email", e.target.value)} />
+ ) :
{identity.email}
}
+
+
+
+ {editMode ? (
+
+ ) : (
+
+ {identity.status === 1 ? "Activa" : "Inactiva"}
+
+ )}
+
+
+ Creada el: {dayjs(identity.updatedAt).format("DD/MM/YYYY")}
+
+
+
+
+ );
+};
+
+AccountCard.propTypes = {
+ identity: PropTypes.object.isRequired,
+ onUpdate: PropTypes.func,
+ onRequestStatusChange: PropTypes.func,
+ confirmedStatusChange: PropTypes.object
+};
+
+export default AccountCard;
\ No newline at end of file
diff --git a/src/components/AnimatedDropdown.jsx b/src/components/AnimatedDropdown.jsx
new file mode 100644
index 0000000..b3e3dd4
--- /dev/null
+++ b/src/components/AnimatedDropdown.jsx
@@ -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 }))
+ : (
+
+ );
+
+ const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
+
+ return (
+
+ {triggerElement}
+
+
+ {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}
+
+ )}
+
+
+ );
+};
+
+export default AnimatedDropdown;
diff --git a/src/components/AnimatedDropend.jsx b/src/components/AnimatedDropend.jsx
new file mode 100644
index 0000000..a092f3f
--- /dev/null
+++ b/src/components/AnimatedDropend.jsx
@@ -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
+ }))
+ : (
+
+ );
+
+ const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
+
+ return (
+
+ {triggerElement}
+
+
+ {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}
+
+ )}
+
+
+ );
+};
+
+export default AnimatedDropend;
diff --git a/src/components/App.jsx b/src/components/App.jsx
index d8c0c93..9646173 100644
--- a/src/components/App.jsx
+++ b/src/components/App.jsx
@@ -1,47 +1,28 @@
-import { useConfig } from "../contexts/ConfigContext.jsx";
-import Card from "./Card.jsx";
+import { Route, Routes } from 'react-router-dom'
+
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faSpinner } from '@fortawesome/free-solid-svg-icons';
-import Footer from "./Footer.jsx";
-import Header from "./Header.jsx";
-import ContentWrapper from "./ContentWrapper.jsx";
-
-function App() {
- const { config, loading, error } = useConfig();
-
- if (loading) {
- return (
-
-
-
- );
- }
-
- if (error) {
- return (
-
-
Error
-
{error.message}
-
- );
- }
+import Header from "@/components/Header.jsx";
+import Home from "@/pages/Home.jsx";
+import Login from "@/pages/Login.jsx";
+import Accounts from "@/pages/Accounts.jsx";
+import ProtectedRoute from '@/components/Auth/ProtectedRoute';
+import Register from '@/pages/Register.jsx';
+const App = () => {
return (
<>
-
-
- {config.map((card, index) => (
-
- ))}
-
-
-
+
+ } />
+ } />
+ } />
+
+
+
+ } />
+
>
);
}
diff --git a/src/components/Auth/IfAuthenticated.jsx b/src/components/Auth/IfAuthenticated.jsx
new file mode 100644
index 0000000..a5efe44
--- /dev/null
+++ b/src/components/Auth/IfAuthenticated.jsx
@@ -0,0 +1,8 @@
+import { useAuth } from "../../hooks/useAuth.js";
+
+const IfAuthenticated = ({ children }) => {
+ const { authStatus } = useAuth();
+ return authStatus === "authenticated" ? children : null;
+};
+
+export default IfAuthenticated;
diff --git a/src/components/Auth/IfNotAuthenticated.jsx b/src/components/Auth/IfNotAuthenticated.jsx
new file mode 100644
index 0000000..4499501
--- /dev/null
+++ b/src/components/Auth/IfNotAuthenticated.jsx
@@ -0,0 +1,8 @@
+import { useAuth } from "../../hooks/useAuth.js";
+
+const IfNotAuthenticated = ({ children }) => {
+ const { authStatus } = useAuth();
+ return authStatus === "unauthenticated" ? children : null;
+};
+
+export default IfNotAuthenticated;
diff --git a/src/components/Auth/IfRole.jsx b/src/components/Auth/IfRole.jsx
new file mode 100644
index 0000000..d990fe2
--- /dev/null
+++ b/src/components/Auth/IfRole.jsx
@@ -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;
diff --git a/src/components/Auth/LoginForm.jsx b/src/components/Auth/LoginForm.jsx
new file mode 100644
index 0000000..7f37850
--- /dev/null
+++ b/src/components/Auth/LoginForm.jsx
@@ -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 (
+
+
+
+
+
+ );
+};
+
+
+export default LoginForm;
diff --git a/src/components/Auth/PasswordInput.jsx b/src/components/Auth/PasswordInput.jsx
new file mode 100644
index 0000000..beae629
--- /dev/null
+++ b/src/components/Auth/PasswordInput.jsx
@@ -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 (
+
+
+
+ Contraseña
+ >
+ }
+ >
+
+
+
+
+
+ );
+};
+
+PasswordInput.propTypes = {
+ value: PropTypes.any,
+ onChange: PropTypes.func,
+ name: PropTypes.string
+}
+
+export default PasswordInput;
diff --git a/src/components/Auth/ProtectedRoute.jsx b/src/components/Auth/ProtectedRoute.jsx
new file mode 100644
index 0000000..161b93a
--- /dev/null
+++ b/src/components/Auth/ProtectedRoute.jsx
@@ -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 ;
+ if (authStatus === "unauthenticated") return ;
+ if (authStatus === "authenticated" && minimumRoles) {
+ const userRole = JSON.parse(localStorage.getItem("identity"))?.metadata?.role;
+ if (!minimumRoles.includes(userRole)) return ;
+ }
+ return children;
+};
+
+export default ProtectedRoute;
diff --git a/src/components/Auth/RegisterForm.jsx b/src/components/Auth/RegisterForm.jsx
new file mode 100644
index 0000000..b454300
--- /dev/null
+++ b/src/components/Auth/RegisterForm.jsx
@@ -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 (
+
+
+
+
+
+ );
+};
+
+
+export default RegisterForm;
diff --git a/src/components/Card.jsx b/src/components/Card.jsx
index f1ef49c..6361896 100644
--- a/src/components/Card.jsx
+++ b/src/components/Card.jsx
@@ -2,14 +2,15 @@ import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import PropTypes from "prop-types";
import { useState, useEffect } from "react";
+import "@/css/Card.css"
-export default function Card({ title, link }) {
+const Card = ({ title, link }) => {
const [image, setImage] = useState("");
const [buttonContent, setButtonContent] = useState(<>{title}>);
useEffect(() => {
const getImage = async () => {
- const response = await fetch("https://api.miarma.net/v1/screenshot?url=" + link);
+ const response = await fetch("https://api.miarma.net/v1/screenshoter?url=" + link);
const blob = await response.blob();
const imageURL = URL.createObjectURL(blob);
setImage(imageURL);
@@ -52,4 +53,6 @@ export default function Card({ title, link }) {
Card.propTypes = {
title: PropTypes.string.isRequired,
link: PropTypes.string.isRequired
-}
\ No newline at end of file
+}
+
+export default Card;
\ No newline at end of file
diff --git a/src/components/CustomContainer.jsx b/src/components/CustomContainer.jsx
new file mode 100644
index 0000000..6d14ffb
--- /dev/null
+++ b/src/components/CustomContainer.jsx
@@ -0,0 +1,15 @@
+import PropTypes from 'prop-types';
+
+const CustomContainer = ({ children }) => {
+ return (
+
+ {children}
+
+ );
+}
+
+CustomContainer.propTypes = {
+ children: PropTypes.node.isRequired,
+}
+
+export default CustomContainer;
\ No newline at end of file
diff --git a/src/components/CustomModal.jsx b/src/components/CustomModal.jsx
new file mode 100644
index 0000000..55d1045
--- /dev/null
+++ b/src/components/CustomModal.jsx
@@ -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 (
+
+
+ {title}
+
+
+
+ {children}
+
+
+ );
+}
+
+export default CustomModal;
\ No newline at end of file
diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx
index 1d056c5..7c37dc8 100644
--- a/src/components/Footer.jsx
+++ b/src/components/Footer.jsx
@@ -1,4 +1,4 @@
-import License from './License';
+import License from '@/components/License';
const Footer = () => {
return (
diff --git a/src/components/Header.jsx b/src/components/Header.jsx
index 93d8576..53aef12 100644
--- a/src/components/Header.jsx
+++ b/src/components/Header.jsx
@@ -1,8 +1,88 @@
+import { Button } from "react-bootstrap";
+import { Link, useLocation, useNavigate } from "react-router-dom";
+import IfNotAuthenticated from "./Auth/IfNotAuthenticated";
+import IfAuthenticated from "./Auth/IfAuthenticated";
+import Avatar from "boring-avatars";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faSignIn, faSignOut, faUserPlus } from "@fortawesome/free-solid-svg-icons";
+import { useAuth } from "@/hooks/useAuth";
+import AnimatedDropdown from "./AnimatedDropdown";
+
const Header = () => {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { identity, logout } = useAuth();
+
return (
-
-

-
+
);
}
diff --git a/src/components/List.jsx b/src/components/List.jsx
new file mode 100644
index 0000000..aad9844
--- /dev/null
+++ b/src/components/List.jsx
@@ -0,0 +1,15 @@
+import ListItem from "./ListItem";
+import {ListGroup} from 'react-bootstrap';
+import '../css/List.css';
+
+const List = ({ datos, config }) => {
+ return (
+
+ {datos.map((item, index) => (
+
+ ))}
+
+ );
+};
+
+export default List;
diff --git a/src/components/ListItem.jsx b/src/components/ListItem.jsx
new file mode 100644
index 0000000..c130e53
--- /dev/null
+++ b/src/components/ListItem.jsx
@@ -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 (
+
+
+ {showIndex && (
+
+ {index + 1}
+
+ )}
+
+ {pfp && item[pfp] && (
+

+ )}
+
+
+ {title && item[title] && (
+
{item[title]}
+ )}
+ {subtitle && item[subtitle] && (
+
{item[subtitle]}
+ )}
+
+
+
+ {numericField && item[numericField] !== undefined && (
+
+ {item[numericField]}
+
+ )}
+
+ );
+};
+
+export default ListItem;
diff --git a/src/components/LoadingIcon.jsx b/src/components/LoadingIcon.jsx
new file mode 100644
index 0000000..e877e43
--- /dev/null
+++ b/src/components/LoadingIcon.jsx
@@ -0,0 +1,10 @@
+import { faSpinner } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+
+const LoadingIcon = () => {
+ return (
+
+ );
+}
+
+export default LoadingIcon;
\ No newline at end of file
diff --git a/src/components/NotificationModal.jsx b/src/components/NotificationModal.jsx
new file mode 100644
index 0000000..aef82c9
--- /dev/null
+++ b/src/components/NotificationModal.jsx
@@ -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 (
+
+
+
+
+ {title}
+
+
+
+
+ {message}
+
+
+
+ {buttons.map((btn, index) => (
+
+ ))}
+
+
+ );
+};
+
+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;
diff --git a/src/components/PaginatedCardGrid.jsx b/src/components/PaginatedCardGrid.jsx
new file mode 100644
index 0000000..c81a6f5
--- /dev/null
+++ b/src/components/PaginatedCardGrid.jsx
@@ -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 (
+
+
+ {items.map((item, i) => renderCard(item, i))}
+
+
+ {loading && }
+
+
+ );
+};
+
+PaginatedCardGrid.propTypes = {
+ items: PropTypes.array,
+ renderCard: PropTypes.func.isRequired,
+ creatingItem: PropTypes.any,
+ renderCreatingCard: PropTypes.func,
+ loaderRef: PropTypes.object,
+ loading: PropTypes.bool
+};
+
+export default PaginatedCardGrid;
diff --git a/src/components/ThemeButton.jsx b/src/components/ThemeButton.jsx
new file mode 100644
index 0000000..ca1ba95
--- /dev/null
+++ b/src/components/ThemeButton.jsx
@@ -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 (
+
+ );
+}
\ No newline at end of file
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx
new file mode 100644
index 0000000..020e1e4
--- /dev/null
+++ b/src/context/AuthContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
diff --git a/src/context/ConfigContext.jsx b/src/context/ConfigContext.jsx
new file mode 100644
index 0000000..25b59bc
--- /dev/null
+++ b/src/context/ConfigContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
+
+ConfigProvider.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+export {ConfigContext};
\ No newline at end of file
diff --git a/src/context/DataContext.jsx b/src/context/DataContext.jsx
new file mode 100644
index 0000000..7887f90
--- /dev/null
+++ b/src/context/DataContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
+
+DataProvider.propTypes = {
+ config: PropTypes.shape({
+ baseUrl: PropTypes.string.isRequired,
+ params: PropTypes.object,
+ }).isRequired,
+ children: PropTypes.node.isRequired,
+};
\ No newline at end of file
diff --git a/src/context/ErrorContext.jsx b/src/context/ErrorContext.jsx
new file mode 100644
index 0000000..1ca0cb9
--- /dev/null
+++ b/src/context/ErrorContext.jsx
@@ -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 (
+
+ {children}
+ {error && (
+
+ )}
+
+ );
+};
+ErrorProvider.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+export const useError = () => useContext(ErrorContext);
diff --git a/src/context/ThemeContext.jsx b/src/context/ThemeContext.jsx
new file mode 100644
index 0000000..043a201
--- /dev/null
+++ b/src/context/ThemeContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
diff --git a/src/contexts/ConfigContext.jsx b/src/contexts/ConfigContext.jsx
deleted file mode 100644
index 7dcbedc..0000000
--- a/src/contexts/ConfigContext.jsx
+++ /dev/null
@@ -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 (
-
- {children}
-
- );
-};
-
-ConfigProvider.propTypes = {
- children: PropTypes.node.isRequired,
-};
-
-export const useConfig = () => useContext(ConfigContext);
diff --git a/src/css/AnimatedDropdown.css b/src/css/AnimatedDropdown.css
new file mode 100644
index 0000000..ba10c6e
--- /dev/null
+++ b/src/css/AnimatedDropdown.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/css/LoginForm.css b/src/css/LoginForm.css
new file mode 100644
index 0000000..88df85d
--- /dev/null
+++ b/src/css/LoginForm.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/css/PaginatedCardGrid.css b/src/css/PaginatedCardGrid.css
new file mode 100644
index 0000000..16ed3c7
--- /dev/null
+++ b/src/css/PaginatedCardGrid.css
@@ -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;
+}
diff --git a/src/css/PasswordInput.css b/src/css/PasswordInput.css
new file mode 100644
index 0000000..b1fc02e
--- /dev/null
+++ b/src/css/PasswordInput.css
@@ -0,0 +1,7 @@
+button.show-button {
+ color: var(--show-btn-color);
+
+}
+button.show-button:hover {
+ color: var(--show-btn-hover);
+}
diff --git a/src/css/index.css b/src/css/index.css
index ad624c4..08e7534 100644
--- a/src/css/index.css
+++ b/src/css/index.css
@@ -27,6 +27,7 @@
}
:root {
+ /* Colores base */
--light: #f6f6f6;
--white: #ffffff;
--blue: #2F6CA3;
@@ -37,6 +38,31 @@
--text-dark: #212529;
--text-light: #ffffff;
--bg: #efefef;
+
+ /* Login / Inputs */
+ --login-bg: var(--white);
+ --text-color: var(--text-dark);
+ --input-bg: var(--light);
+ --input-text: var(--text-dark);
+ --input-border: var(--muted);
+ --label-color: var(--muted);
+ --placeholder-color: var(--muted);
+ --input-focus-shadow: rgba(47, 108, 163, 0.25);
+
+ /* Botones */
+ --show-btn-color: var(--blue);
+ --show-btn-hover: var(--blue-dark);
+ --accent-color: var(--blue);
+
+ /* Dropdown / Navbar */
+ --navbar-bg: var(--white);
+ --navbar-dropdown-item-color: var(--text-dark);
+ --divider-color: #dee2e6;
+ --shadow-color: rgba(0, 0, 0, 0.15);
+
+ /* Estados / Extra */
+ --secondary-color: var(--blue-dark);
+ --muted-color: var(--muted);
}
html,
@@ -86,4 +112,59 @@ footer {
margin-top: 30px;
text-align: center;
color: var(--muted);
+}
+
+.modal-body {
+ background-color: transparent;
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem;
+ border: none;
+ background-color: var(--modal-bg);
+}
+
+.themed-input {
+ background-color: var(--input-bg) !important;
+ color: var(--input-text) !important;
+ border: 1px solid var(--input-border) !important;
+ border-radius: 8px;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.9rem;
+
+}
+
+.themed-input:focus {
+ border-color: var(--accent-color) !important;
+ box-shadow: 0 0 0 0.25rem var(--input-focus-shadow);
+ outline: none;
+}
+
+.themed-input::placeholder {
+ color: var(--placeholder-color) !important;
+}
+
+.hidden {
+ display: none;
+}
+
+.logout {
+ color: rgb(215, 48, 48);
+ transition: all 0.2s;
+}
+
+.logout:hover {
+ color: rgb(162, 26, 26);
+ transition: all 0.2s;
+}
+
+.cursor-pointer {
+ cursor: pointer;
+}
+
+.muted {
+ color: #868686;
}
\ No newline at end of file
diff --git a/src/hooks/useAuth.js b/src/hooks/useAuth.js
new file mode 100644
index 0000000..a6e5c3a
--- /dev/null
+++ b/src/hooks/useAuth.js
@@ -0,0 +1,4 @@
+import { useContext } from "react";
+import { AuthContext } from "../context/AuthContext";
+
+export const useAuth = () => useContext(AuthContext);
diff --git a/src/hooks/useConfig.js b/src/hooks/useConfig.js
new file mode 100644
index 0000000..a895e6b
--- /dev/null
+++ b/src/hooks/useConfig.js
@@ -0,0 +1,4 @@
+import { useContext } from "react";
+import { ConfigContext } from "../context/ConfigContext.jsx";
+
+export const useConfig = () => useContext(ConfigContext);
diff --git a/src/hooks/useData.js b/src/hooks/useData.js
new file mode 100644
index 0000000..9ad7365
--- /dev/null
+++ b/src/hooks/useData.js
@@ -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)
+ };
+};
diff --git a/src/hooks/useDataContext.js b/src/hooks/useDataContext.js
new file mode 100644
index 0000000..f4b6a80
--- /dev/null
+++ b/src/hooks/useDataContext.js
@@ -0,0 +1,4 @@
+import { useContext } from "react";
+import { DataContext } from "../context/DataContext";
+
+export const useDataContext = () => useContext(DataContext);
diff --git a/src/hooks/useRequestCount.js b/src/hooks/useRequestCount.js
new file mode 100644
index 0000000..6f302ca
--- /dev/null
+++ b/src/hooks/useRequestCount.js
@@ -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;
diff --git a/src/hooks/useSessionRenewal.jsx b/src/hooks/useSessionRenewal.jsx
new file mode 100644
index 0000000..ab187e0
--- /dev/null
+++ b/src/hooks/useSessionRenewal.jsx
@@ -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 && (
+ {
+ 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;
diff --git a/src/hooks/useTheme.js b/src/hooks/useTheme.js
new file mode 100644
index 0000000..026c44b
--- /dev/null
+++ b/src/hooks/useTheme.js
@@ -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 ");
+ }
+ return context;
+};
diff --git a/src/main.jsx b/src/main.jsx
index f96eb9a..e0c17ee 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,14 +1,25 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
-import App from './components/App.jsx'
-import { ConfigProvider } from './contexts/ConfigContext.jsx'
-import './css/index.css';
-import './css/Card.css';
+import App from '@/components/App.jsx'
+import { ConfigProvider } from '@/context/ConfigContext.jsx'
+import { BrowserRouter } from 'react-router-dom'
+import { ThemeProvider } from '@/context/ThemeContext'
+import { AuthProvider } from '@/context/AuthContext'
+import { ErrorProvider } from '@/context/ErrorContext.jsx'
+import '@/css/index.css';
createRoot(document.getElementById('root')).render(
-
+
+
+
+
+
+
+
+
+
,
)
diff --git a/src/pages/Accounts.jsx b/src/pages/Accounts.jsx
new file mode 100644
index 0000000..9ec3289
--- /dev/null
+++ b/src/pages/Accounts.jsx
@@ -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
;
+
+ 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 (
+
+
+
+ );
+}
+
+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
;
+
+ return (
+
+
+ (
+
+ )}
+ />
+
+
+ {showConfirmModal && (
+ setShowConfirmModal(false)}
+ title="Confirmar desactivación"
+ >
+
+
¿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.
+
+
+
+
+
+
+ )}
+
+ );
+}
+
+AccountsContent.propTypes = {
+ reqConfig: PropTypes.object.isRequired
+};
+
+export default Accounts;
\ No newline at end of file
diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx
new file mode 100644
index 0000000..0390f7f
--- /dev/null
+++ b/src/pages/Home.jsx
@@ -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
;
+
+ return (
+
+ );
+}
+
+const HomeContent = ({ cards }) => {
+ return (
+
+
+
+ {cards.map((card, index) => (
+
+ ))}
+
+
+
+ );
+}
+
+HomeContent.propTypes = {
+ cards: PropTypes.array.isRequired
+};
+
+export default Home;
\ No newline at end of file
diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx
new file mode 100644
index 0000000..5cfea64
--- /dev/null
+++ b/src/pages/Login.jsx
@@ -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 (
+
+ );
+}
+
+export default Login;
\ No newline at end of file
diff --git a/src/pages/Register.jsx b/src/pages/Register.jsx
new file mode 100644
index 0000000..d054bff
--- /dev/null
+++ b/src/pages/Register.jsx
@@ -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 (
+
+ );
+}
+
+export default Register;
\ No newline at end of file
diff --git a/src/util/array.js b/src/util/array.js
new file mode 100644
index 0000000..7480a5d
--- /dev/null
+++ b/src/util/array.js
@@ -0,0 +1,5 @@
+const random = (arr) => {
+ return arr[Math.floor(Math.random() * arr.length)]
+}
+
+export { random }
\ No newline at end of file
diff --git a/src/util/constants.js b/src/util/constants.js
new file mode 100644
index 0000000..a28f79e
--- /dev/null
+++ b/src/util/constants.js
@@ -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 };
diff --git a/vite.config.js b/vite.config.js
index e616e93..2236b06 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -7,5 +7,14 @@ export default defineConfig({
build: {
outDir: 'dist',
},
+ server: {
+ host: "0.0.0.0",
+ port: 3000,
+ },
+ resolve: {
+ alias: {
+ '@/': '/src/',
+ },
+ },
publicDir: 'public',
})