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) => ( - - ))} -
-
-