[REPO REFACTOR]: changed to a better git repository structure with branches

This commit is contained in:
2025-11-01 05:37:33 +01:00
parent 1f77767d59
commit c72cf3c373
79 changed files with 3782 additions and 0 deletions

38
eslint.config.js Normal file
View File

@@ -0,0 +1,38 @@
import js from '@eslint/js'
import globals from 'globals'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="es" data-bs-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="shortcut icon" href="/images/favicon.ico" type="image/x-icon">
<title>MiarmaCraft</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

9
jsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "miarmacraftreact",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"axios": "^1.9.0",
"bootstrap": "^5.3.5",
"date-fns": "^2.30.0",
"dompurify": "^3.2.5",
"file-saver": "^2.0.5",
"framer-motion": "^12.6.1",
"pixelarticons": "^1.8.1",
"react": "^18.3.1",
"react-bootstrap": "^2.10.9",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.5",
"react-simple-wysiwyg": "^3.2.2",
"react-skinview3d": "^5.1.0",
"react-slick": "^0.30.3",
"react-split": "^2.0.14",
"slick-carousel": "^1.8.1",
"vite-plugin-clean": "^2.0.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"vite": "^6.0.5"
}
}

View File

@@ -0,0 +1,32 @@
{
"apiConfig": {
"baseUrl": "https://api.miarma.net/mmc/v1",
"baseRawUrl": "https://api.miarma.net/mmc/raw/v1",
"coreUrl": "https://api.miarma.net/v1",
"coreRawUrl": "https://api.miarma.net/raw/v1",
"authUrl": "https://api.miarma.net/auth/v1",
"endpoints": {
"auth": {
"login": "/login",
"validateToken": "/validate-token",
"refreshToken": "/refresh-token",
"changePassword": "/change-password",
"loginValidate": "/login/validate"
},
"mods": {
"all": "/mods",
"byId": "/mods/:mod_id",
"modStatus": "/mods/:mod_id/status"
},
"players": {
"all": "/players",
"byId": "/players/:player_id",
"playerStatus": "/players/:player_id/status",
"playerRole": "/players/:player_id/role",
"playerExists": "/players/:player_id/exists",
"playerAvatar": "/players/:player_id/avatar",
"playerInfo": "/players/me"
}
}
}
}

View File

@@ -0,0 +1,32 @@
{
"apiConfig": {
"baseUrl": "https://api.miarma.net/mmc/v1",
"baseRawUrl": "https://api.miarma.net/mmc/raw/v1",
"coreUrl": "https://api.miarma.net/v1",
"coreRawUrl": "https://api.miarma.net/raw/v1",
"authUrl": "https://api.miarma.net/auth/v1",
"endpoints": {
"auth": {
"login": "/login",
"validateToken": "/validate-token",
"refreshToken": "/refresh-token",
"changePassword": "/change-password",
"loginValidate": "/login/validate"
},
"mods": {
"all": "/mods",
"byId": "/mods/:mod_id",
"modStatus": "/mods/:mod_id/status"
},
"players": {
"all": "/players",
"byId": "/players/:player_id",
"playerStatus": "/players/:player_id/status",
"playerRole": "/players/:player_id/role",
"playerExists": "/players/:player_id/exists",
"playerAvatar": "/players/:player_id/avatar",
"playerInfo": "/players/me"
}
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/fonts/mc-titles.ttf Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
public/images/bg_dirt.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/images/building.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

BIN
public/images/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 301 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 68 KiB

BIN
public/images/sign.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

BIN
public/images/title.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

BIN
public/images/title.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

6
public/privacy.txt Normal file
View File

@@ -0,0 +1,6 @@
privacy.txt
1. No recopilamos ningun dato personal.
2. No mandaremos correos basura en la lista de correo.
3. No usaremos cookies de terceros.
4. Es bastante probable que usemos tu direccion IPv4 por motivos de funcionamiento del servidor.

14
src/api/axiosInstance.js Normal file
View File

@@ -0,0 +1,14 @@
import axios from "axios";
const createAxiosInstance = (baseURL, token) => {
const instance = axios.create({
baseURL,
headers: {
...(token && { Authorization: `Bearer ${token}` }),
},
});
return instance;
};
export default createAxiosInstance;

43
src/components/App.jsx Normal file
View File

@@ -0,0 +1,43 @@
import { Route, Routes, useLocation } from "react-router-dom";
import Header from "./layout/Header";
import Inicio from "./pages/Inicio";
import Mods from "./pages/Mods";
import Jugadores from "./pages/Jugadores";
import Footer from "./layout/Footer";
import Login from "./pages/Login";
import Profile from "./pages/Profile";
import ProtectedRoute from "./auth/ProtectedRoute";
import { CONSTANTS } from "@/constants";
const App = () => {
const location = useLocation().pathname.replace(import.meta.env.BASE_URL, '/');
const routesWithFooter = ["/", "/login"]
return (
<>
<Header />
<Routes>
<Route path="/" element={<Inicio />} />
<Route path="/mods" element={
<ProtectedRoute minimumRoles={[CONSTANTS.ADMIN_ROLE]}>
<Mods />
</ProtectedRoute>
} />
<Route path="/jugadores" element={
<ProtectedRoute minimumRoles={[CONSTANTS.ADMIN_ROLE]}>
<Jugadores />
</ProtectedRoute>
} />
<Route path="/login" element={<Login />} />
<Route path="/perfil" element={
<ProtectedRoute>
<Profile />
</ProtectedRoute>
} />
<Route path="/privacidad" element={null} />
</Routes>
{routesWithFooter.includes(location) ? <Footer /> : null}
</>
);
}
export default App;

View File

@@ -0,0 +1,25 @@
import PropTypes from "prop-types";
import Icons from "@/icons.jsx";
const CustomCheckbox = ({ checked, onChange, label }) => {
const handleToggle = () => {
onChange(!checked);
};
return (
<div className="minecraft-checkbox-wrapper" onClick={handleToggle}>
<div className={`minecraft-checkbox ${checked ? "checked" : ""}`}>
{checked ? Icons.CheckboxOn : Icons.CheckboxOff}
</div>
{label && <small className="minecraft-checkbox-label">{label}</small>}
</div>
);
};
CustomCheckbox.propTypes = {
checked: PropTypes.bool.isRequired,
onChange: PropTypes.func.isRequired,
label: PropTypes.string,
};
export default CustomCheckbox;

View File

@@ -0,0 +1,8 @@
import { useAuth } from "@/hooks/useAuth.js";
const IfAuthenticated = ({ children }) => {
const { authStatus } = useAuth();
return authStatus === "authenticated" ? children : null;
};
export default IfAuthenticated;

View File

@@ -0,0 +1,8 @@
import { useAuth } from "@/hooks/useAuth.js";
const IfNotAuthenticated = ({ children }) => {
const { authStatus } = useAuth();
return authStatus === "unauthenticated" ? children : null;
};
export default IfNotAuthenticated;

View File

@@ -0,0 +1,13 @@
import { useAuth } from "@/hooks/useAuth.js";
const IfRole = ({ roles, children }) => {
const { user, authStatus } = useAuth();
if (authStatus !== "authenticated") return null;
const userRole = user?.role;
return roles.includes(userRole) ? children : null;
};
export default IfRole;

View File

@@ -0,0 +1,102 @@
import CustomContainer from "@/components/layout/CustomContainer";
import { useAuth } from "@/hooks/useAuth";
import PasswordInput from "./PasswordInput";
import { Alert } from "react-bootstrap";
import CustomCheckbox from "./CustomCheckbox";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import PropTypes from "prop-types";
const LoginForm = () => {
const { login, error } = useAuth();
const navigate = useNavigate();
const [formState, setFormState] = useState({
emailOrUserName: "",
password: "",
keepLoggedIn: false
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormState((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formState.emailOrUserName);
const loginBody = {
password: formState.password,
keepLoggedIn: Boolean(formState.keepLoggedIn),
};
if (isEmail) {
loginBody.email = formState.emailOrUserName;
} else {
loginBody.userName = formState.emailOrUserName;
}
try {
await login(loginBody);
navigate("/");
} catch (err) {
console.error("Error de login:", err.message);
}
};
return (
<CustomContainer>
<div className="minecraft-card mx-auto d-flex flex-column gap-4 col-12 col-md-8 col-lg-6 col-xl-5 my-5">
<div className="card-body">
<h1 className="text-center">Inicio de sesion</h1>
<hr className="minecraft-hr" />
{error && (
<Alert variant="danger" className="text-center py-2 mb-3">
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<label>Usuario o email</label>
<input
type="text"
name="emailOrUserName"
value={formState.emailOrUserName}
onChange={handleChange}
className="minecraft-input mb-3" />
<label>Contraseña</label>
<PasswordInput
value={formState.password}
onChange={handleChange}
name="password"
className="mb-5"
/>
<CustomCheckbox
checked={formState.keepLoggedIn}
onChange={(newValue) => setFormState(prev => ({ ...prev, keepLoggedIn: newValue }))}
label="Mantener sesión iniciada"
/>
<hr className="minecraft-hr" />
<div className="text-center">
<button className="minecraft-btn" type="submit">
Iniciar sesion
</button>
</div>
</form>
</div>
</div>
</CustomContainer>
);
}
LoginForm.propTypes = {
emailOrUsername: PropTypes.string,
password: PropTypes.string,
};
export default LoginForm;

View File

@@ -0,0 +1,43 @@
import { useState } from 'react';
import { Button } from 'react-bootstrap';
import '../../css/PasswordInput.css';
import PropTypes from 'prop-types';
import Icons from '../../icons.jsx';
const PasswordInput = ({ value, onChange, name = "password", className }) => {
const [show, setShow] = useState(false);
const toggleShow = () => setShow(prev => !prev);
return (
<div className={`position-relative w-100 ${className}`}>
<input
type={show ? "text" : "password"}
name={name}
value={value}
placeholder=""
onChange={onChange}
className="minecraft-input" />
<Button
variant="link"
className="show-button position-absolute end-0 top-50 translate-middle-y"
onClick={toggleShow}
aria-label="Mostrar contraseña"
tabIndex={-1}
style={{ zIndex: 2 }}
>
{show ? Icons.EyeSlash : Icons.Eye}
</Button>
</div>
);
};
PasswordInput.propTypes = {
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
name: PropTypes.string,
className: PropTypes.string,
};
export default PasswordInput;

View File

@@ -0,0 +1,20 @@
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
const ProfilePicture = ({ userName, part }) => {
const src = `https://mineskin.eu/${part}/${userName}/40.png?v=${Date.now()}`;
return (
<Link to="/perfil" className='navbar-brand align-items-center'>
<img src={src}
width="40" height="40" className="d-inline-block m-0 p-0"
/>
</Link>
);
}
ProfilePicture.propTypes = {
userName: PropTypes.string,
part: PropTypes.string,
};
export default ProfilePicture;

View File

@@ -0,0 +1,18 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth.js";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
const ProtectedRoute = ({ minimumRoles, children }) => {
const { authStatus } = useAuth();
if (authStatus === "checking") return <FontAwesomeIcon icon={faSpinner} />; // o un loader si quieres
if (authStatus === "unauthenticated") return <Navigate to="/login" replace />;
if (authStatus === "authenticated" && minimumRoles) {
const userRole = JSON.parse(localStorage.getItem("user"))?.role;
if (!minimumRoles.includes(userRole)) return <Navigate to="/" replace />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,109 @@
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import { CloseButton } from "react-bootstrap";
import "@/css/FileUpload.css";
import PropTypes from "prop-types";
const MAX_FILE_SIZE_MB = 100;
const FileUpload = forwardRef(({ onFilesSelected }, ref) => {
const fileInputRef = useRef();
const [highlight, setHighlight] = useState(false);
const [selectedFiles, setSelectedFiles] = useState([]);
useImperativeHandle(ref, () => ({
getSelectedFiles: () => selectedFiles,
resetSelectedFiles: () => setSelectedFiles([]),
}));
const handleFiles = (files) => {
const validFiles = Array.from(files).filter(
(file) => file.size <= MAX_FILE_SIZE_MB * 1024 * 1024
);
setSelectedFiles(validFiles);
if (onFilesSelected) onFilesSelected(validFiles);
};
const handleInputChange = (e) => {
handleFiles(e.target.files);
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setHighlight(false);
handleFiles(e.dataTransfer.files);
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
setHighlight(true);
};
const handleDragLeave = () => {
setHighlight(false);
};
const openFileDialog = () => {
fileInputRef.current?.click();
};
const removeFile = (index) => {
const updated = [...selectedFiles];
updated.splice(index, 1);
setSelectedFiles(updated);
if (onFilesSelected) onFilesSelected(updated);
};
return (
<div
className={`upload-card mb-4 ${highlight ? "highlight" : ""}`}
onClick={openFileDialog}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
role="button"
>
<div className="text-center">
<h2 className="mb-3">📎 Subir archivo</h2>
<p>
Arrastra o haz click para seleccionar archivos (Máx. 100MB)
</p>
<input
ref={fileInputRef}
type="file"
accept=".jar"
multiple
className="d-none"
onChange={handleInputChange}
/>
{selectedFiles.length > 0 && (
<ul className="file-list text-start mt-4 px-3">
{selectedFiles.map((file, idx) => (
<li
key={idx}
className="d-flex justify-content-between align-items-center mb-2"
>
<span>📄 {file.name}</span>
<CloseButton
onClick={(e) => {
e.stopPropagation();
removeFile(idx);
}}
/>
</li>
))}
</ul>
)}
</div>
</div>
);
});
FileUpload.displayName = "FileUpload";
FileUpload.propTypes = {
onFilesSelected: PropTypes.func,
};
export default FileUpload;

View File

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

View File

@@ -0,0 +1,24 @@
import DatePicker, { registerLocale } from 'react-datepicker';
import es from 'date-fns/locale/es';
import 'react-datepicker/dist/react-datepicker.css';
registerLocale('es', es);
const SpanishDateTimePicker = ({ selected, onChange }) => {
return (
<DatePicker
selected={selected}
onChange={onChange}
showTimeSelect
timeFormat="HH:mm"
timeIntervals={15}
dateFormat="dd/MM/yyyy HH:mm"
timeCaption="Hora"
locale="es"
className="form-control themed-input"
/>
);
};
export default SpanishDateTimePicker;

View File

@@ -0,0 +1,24 @@
import CustomContainer from '@/components/layout/CustomContainer';
import { Row, Col } from 'react-bootstrap';
const Building = () => {
return (
<CustomContainer>
<Row className="justify-content-center">
<Col xs={12} md={6} className="d-flex">
<div className="minecraft-card text-center flex-fill p-5">
<img
src={`${import.meta.env.BASE_URL}images/building.webp`}
alt="Página en construcción"
className="construction-img img-fluid mb-4"
/>
<h1>Pagina en construccion</h1>
<p>Estamos trabajando en ello. ¡Vuelve pronto!</p>
</div>
</Col>
</Row>
</CustomContainer>
);
};
export default Building;

View File

@@ -0,0 +1,16 @@
import PropTypes from 'prop-types';
const ContentWrapper = ({ children, row = false }) => {
return (
<div className={`container-xl ${row ? 'row' : ''} mx-auto`}>
{children}
</div>
);
}
ContentWrapper.propTypes = {
children: PropTypes.node.isRequired,
row: PropTypes.bool,
}
export default ContentWrapper;

View File

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

View File

@@ -0,0 +1,15 @@
import PropTypes from 'prop-types';
const CustomContainer = ({ children }) => {
return (
<main className={`px-4 py-5`}>
{children}
</main>
);
}
CustomContainer.propTypes = {
children: PropTypes.node.isRequired
}
export default CustomContainer;

View File

@@ -0,0 +1,20 @@
import PropTypes from "prop-types";
const Footer = () => (
<footer className={`minecraft-footer py-5`}>
<div className="footer-content">
<p>© 2025 <a href="https://miarma.net/">miarma.net</a> | Todos los derechos reservados.</p>
<div className="footer-links">
<a href="/miarmacraft/privacy.txt" className="minecraft-btn">
Politica de Privacidad
</a>
</div>
</div>
</footer>
);
Footer.propTypes = {
sticky: PropTypes.bool,
};
export default Footer;

View File

@@ -0,0 +1,139 @@
import { useState } from 'react';
import { Container, Collapse } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import IfAuthenticated from '@/components/auth/IfAuthenticated.jsx';
import IfNotAuthenticated from '@/components/auth/IfNotAuthenticated.jsx';
import IfRole from '@/components/auth/IfRole.jsx';
import { CONSTANTS } from '@/constants.js';
import { useAuth } from '@/hooks/useAuth.js';
import ProfilePicture from '@/components/auth/ProfilePicture.jsx';
import Icons from '@/icons.jsx';
const Header = () => {
const [open, setOpen] = useState(false);
const { user, logout } = useAuth();
const toggleMenu = () => setOpen(!open);
const closeMenu = () => setOpen(false);
return (
<header className="header position-relative glassmorphism sticky-top">
<Container fluid className="d-flex justify-content-between align-items-center p-0">
<div className="header-logo">
<Link to="/">
<img
src={`${import.meta.env.BASE_URL}images/miarmacraft.svg`}
className="img-fluid"
style={{ maxHeight: '60px' }}
/>
</Link>
</div>
<button
className="menu-toggle d-lg-none"
onClick={toggleMenu}
aria-controls="header-collapse"
aria-expanded={open}
>
{open ? Icons.Close : Icons.Menu}
</button>
<nav className="header-nav d-none d-lg-flex gap-4 align-items-center">
<Link to="" className='nav-link'>
<div className='align-items-center d-flex gap-2'>
{Icons.Home}
INICIO
</div>
</Link>
<IfRole roles={[CONSTANTS.ADMIN_ROLE]}>
<Link to="mods" className='nav-link'>
<div className='align-items-center d-flex gap-2'>
{Icons.AddGrid}
MODS
</div>
</Link>
</IfRole>
<IfRole roles={[CONSTANTS.ADMIN_ROLE]}>
<Link to="jugadores" className='nav-link'>
<div className='align-items-center d-flex gap-2'>
{Icons.Users}
JUGADORES
</div>
</Link>
</IfRole>
<IfAuthenticated>
<ProfilePicture userName={user?.user_name} part="helm" />
</IfAuthenticated>
<IfNotAuthenticated>
<ProfilePicture userName="Steve" part="avatar" />
</IfNotAuthenticated>
<IfNotAuthenticated>
<Link to="login">
<button className="minecraft-btn">
<div className='align-items-center d-flex gap-2'>
{Icons.Login}
INICIAR SESION
</div>
</button>
</Link>
</IfNotAuthenticated>
<IfAuthenticated>
<button className="minecraft-btn danger" onClick={logout}>
<div className='align-items-center d-flex gap-2'>
{Icons.Logout}
CERRAR SESION
</div>
</button>
</IfAuthenticated>
</nav>
</Container>
<Collapse in={open}>
<div id="header-collapse" className="header-nav-mobile d-lg-none">
<Link to="" className='nav-link mt-4' onClick={closeMenu}>
<div className='align-items-center d-flex gap-2'>
{Icons.Home}
INICIO
</div>
</Link>
<IfRole roles={[CONSTANTS.ADMIN_ROLE, CONSTANTS.PLAYER_ROLE]}>
<Link to="mods" className='nav-link mt-4' onClick={closeMenu}>
<div className='align-items-center d-flex gap-2'>
{Icons.AddGrid}
MODS
</div>
</Link>
</IfRole>
<IfRole roles={[CONSTANTS.ADMIN_ROLE]}>
<Link to="jugadores" className='nav-link mt-4' onClick={closeMenu}>
<div className='align-items-center d-flex gap-2'>
{Icons.Users}
JUGADORES
</div>
</Link>
</IfRole>
<IfNotAuthenticated>
<Link to="login">
<button className="minecraft-btn mt-4" onClick={closeMenu}>
<div className='align-items-center d-flex gap-2'>
{Icons.Login}
INICIAR SESION
</div>
</button>
</Link>
</IfNotAuthenticated>
<IfAuthenticated>
<button className="minecraft-btn danger mt-4" onClick={() => { logout(); closeMenu(); }}>
<div className='align-items-center d-flex gap-2'>
{Icons.Logout}
CERRAR SESION
</div>
</button>
</IfAuthenticated>
</div>
</Collapse>
</header>
);
}
export default Header;

View File

@@ -0,0 +1,24 @@
import LoadingIcon from '@/components/util/LoadingIcon';
const PaginatedCardGrid = ({
items = [],
renderCard,
creatingItem = null,
renderCreatingCard = null,
loaderRef,
loading = false
}) => {
return (
<div className="cards-grid">
{creatingItem && renderCreatingCard && renderCreatingCard()}
{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>
);
};
export default PaginatedCardGrid;

View File

@@ -0,0 +1,28 @@
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 = null, children }) => {
return (
<Modal show={show} onHide={onClose} size="md" centered>
{title && (
<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;

View File

@@ -0,0 +1,69 @@
import PropTypes from 'prop-types';
import { Modal, Button } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCircleCheck,
faCircleXmark,
faCircleExclamation,
faCircleInfo
} from '@fortawesome/free-solid-svg-icons';
const iconMap = {
success: faCircleCheck,
danger: faCircleXmark,
warning: faCircleExclamation,
info: faCircleInfo
};
const NotificationModal = ({
show,
onClose,
title,
message,
variant = "info",
buttons = [{ label: "Aceptar", variant: "primary", onClick: onClose }]
}) => {
return (
<Modal show={show} onHide={onClose} centered>
<Modal.Header closeButton className={`bg-${variant} ${variant === 'info' ? 'text-dark' : 'text-white'}`}>
<Modal.Title>
<FontAwesomeIcon icon={iconMap[variant] || faCircleInfo} className="me-2" />
{title}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="mb-0">{message}</p>
</Modal.Body>
<Modal.Footer>
{buttons.map((btn, index) => (
<Button
key={index}
variant={btn.variant || "primary"}
onClick={btn.onClick || onClose}
>
{btn.label}
</Button>
))}
</Modal.Footer>
</Modal>
);
};
NotificationModal.propTypes = {
show: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
variant: PropTypes.oneOf(['success', 'danger', 'warning', 'info']),
buttons: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
variant: PropTypes.string,
onClick: PropTypes.func
})
)
};
export default NotificationModal;

137
src/components/mods/Mod.jsx Normal file
View File

@@ -0,0 +1,137 @@
import Icons from "@/icons";
import PropTypes from "prop-types";
import IfRole from "../auth/IfRole";
import { CONSTANTS } from "@/constants";
import AnimatedDropdown from "../util/AnimatedDropdown";
import FileUpload from '@/components/inputs/FileUpload';
import { useState } from "react";
const Mod = ({ mod, isNew, fileRef, onCreate, onUpdate, onDelete, onSelectFiles, onCancel, onClearError }) => {
const isActive = mod?.status === 1;
const [editMode, setEditMode] = useState(isNew);
const [modData, setModData] = useState({
name: mod?.name || "Mod nuevo",
url: mod?.url || "no",
status: mod?.status ?? 1,
});
const createMode = isNew;
const handleChange = (K, V) => {
setModData((prev) => ({ ...prev, [K]: V }))
}
const handleDelete = () => typeof onDelete === "function" && onDelete(mod.mod_id);
const handleSave = () => {
const data = { ...mod, ...modData };
if (createMode && onCreate) onCreate(data);
else if (onUpdate) onUpdate(data, mod.mod_id);
}
const handleEdit = () => {
if (onClearError) onClearError();
setEditMode(true);
};
const handleCancel = () => {
if (onClearError) onClearError();
if (createMode && typeof onCancel === 'function') return onCancel();
setEditMode(false);
};
if (editMode) {
return (
<div className="d-flex flex-column align-items-center gap-3">
<div className="d-flex align-items-center w-100 gap-1">
<input className="minecraft-input col-10" value={modData.name} onChange={(e) => handleChange("name", e.target.value)} />
<select className="minecraft-select col-md-1 col-2" value={modData.status} onChange={(e) => handleChange("status", parseInt(e.target.value))}>
<option value={1}></option>
<option value={0}></option>
</select>
</div>
{/* Solo se muestra cuando editas un mod existente */}
{!createMode && (
<input
className="minecraft-input col-12"
value={modData.url}
onChange={(e) => handleChange("url", e.target.value)}
/>
)}
{createMode && <FileUpload ref={fileRef} onFilesSelected={onSelectFiles} />}
<div className="d-flex w-100 justify-content-end gap-2">
<button className="minecraft-btn" onClick={handleSave}>
{Icons.Save}
</button>
<button className="minecraft-btn danger" onClick={handleCancel}>
{Icons.Cancel}
</button>
</div>
</div>
);
}
// Modo visual normal
return (
<li
className="d-flex justify-content-between align-items-center py-2"
style={{ gap: "1rem", fontFamily: "'MCText Regular'", fontSize: "1.3rem" }}
>
<div className="d-flex align-items-center gap-2">
{isActive ? Icons.FilePlus : Icons.Trash}
<span className="text-wrap">{mod.name}</span>
</div>
<div className="m-0 p-0 d-flex align-items-center gap-2 flex-wrap justify-content-end">
{mod.url !== "no" && isActive && (
<a
href={mod.url}
className="minecraft-btn flex-shrink-0"
target="_blank"
rel="noopener noreferrer"
>
{Icons.Download}
</a>
)}
<IfRole roles={[CONSTANTS.ADMIN_ROLE]}>
<AnimatedDropdown
trigger={
<button className="minecraft-btn flex-shrink-0">
{Icons.Dots}
</button>
}
className="end-0"
>
{({ closeDropdown }) => (
<>
<div className="dropdown-item d-flex align-items-center gap-2" onClick={() => { handleEdit(); closeDropdown(); }}>
{Icons.Edit} Editar
</div>
<hr className="dropdown-divider" />
<div className="dropdown-item d-flex align-items-center gap-2" style={{ color: "var(--removed-color)" }} onClick={() => { handleDelete(); closeDropdown(); }}>
{Icons.Trash} Eliminar
</div>
</>
)}
</AnimatedDropdown>
</IfRole>
</div>
</li>
);
};
Mod.propTypes = {
mod: PropTypes.object,
isNew: PropTypes.bool,
fileRef: PropTypes.object,
onCreate: PropTypes.func,
onUpdate: PropTypes.func,
onDelete: PropTypes.func,
onSelectFiles: PropTypes.func,
onCancel: PropTypes.func,
onClearError: PropTypes.func,
};
export default Mod;

View File

@@ -0,0 +1,66 @@
import Mod from "./Mod";
import PropTypes from "prop-types";
const groupModsByDate = (mods) => {
const map = {};
mods.forEach((mod) => {
const dateObj = new Date(mod.created_at);
const yyyy = dateObj.getFullYear();
const mm = String(dateObj.getMonth() + 1).padStart(2, "0");
const dd = String(dateObj.getDate()).padStart(2, "0");
const localDate = `${yyyy}-${mm}-${dd}`;
if (!map[localDate]) map[localDate] = [];
map[localDate].push(mod);
});
return map;
};
const formatDate = (dateStr) => {
const date = new Date(dateStr);
const today = new Date().toDateString();
return date.toDateString() === today ? "HOY" : date.toLocaleDateString("es-ES");
};
const ModListByDate = ({ mods, onUpdate, onDelete, onClearError }) => {
const modsByDate = groupModsByDate(mods);
const sortedDates = Object.keys(modsByDate).sort((a, b) => new Date(b) - new Date(a));
return (
<div className="minecraft-card not-animated p-4 col-xs-12">
{sortedDates.map((date) => (
<div key={date} className="mb-4">
<h3 className="mb-2 header" style={{ fontSize: "1.6rem" }}>
{formatDate(date)}
</h3>
<ul className="list-unstyled m-0 p-0">
{modsByDate[date]
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.map((mod) => (
<Mod
key={mod.mod_id}
mod={mod}
onUpdate={onUpdate}
onDelete={onDelete}
onClearError={onClearError}
/>
))}
</ul>
<hr className="minecraft-hr" />
</div>
))}
</div>
);
};
ModListByDate.propTypes = {
mods: PropTypes.arrayOf(
PropTypes.shape({
mod_id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
created_at: PropTypes.string.isRequired,
})
).isRequired,
onUpdate: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
onClearError: PropTypes.func.isRequired
};
export default ModListByDate;

View File

@@ -0,0 +1,113 @@
import CustomContainer from "@/components/layout/CustomContainer";
import { Col, Row, Modal } from "react-bootstrap";
import { useState } from "react";
import { Link } from "react-router-dom";
import ContentWrapper from "../layout/ContentWrapper";
const Inicio = () => {
const [modalShown, setModalShown] = useState(false);
const copiarIP = (mode) => {
navigator.clipboard.writeText(mode === 'V' ? 'miarma.net' : 'miarma.net:25566');
setModalShown(true);
};
return (
<CustomContainer>
<ContentWrapper>
<h1 className="text-center mt3 mb-5">Pasos para unirse al servidor</h1>
<Row className="g-4 align-items-stretch mb-5">
{[1, 2, 3].map((step) => (
<Col key={step} sm={12} md={4} className="d-flex">
<div className="minecraft-card flex-fill d-flex flex-column">
{/* —— Contenido “arriba” ————————————————————— */}
<h1 className="header text-center">
Paso {step}
</h1>
<div className="card-body">
{step === 1 && (
<>
<p>Necesitas tener el juego para entrar en el servidor (gracias capitán obvio) así que tienes dos opciones:
</p>
<ul>
<li className="text-start">Comprarlo en la página oficial.</li>
<li className="text-start">Descargar el launcher SKLauncher (no recomendado).</li>
</ul>
</>
)}
{step === 2 && (
<>
<p>
Para jugar al server Vanilla++ deberás entrar en la versión 1.21.8 vanilla sin más.
</p>
<p>
Sin embargo, si deseas jugar al servidor con mods necesitas descargar el modpack (paquete de mods) que tenemos en el servidor. En caso de que necesitases algún mod específico, puedes mirarlo en la <Link to={"/mods"}>lista de mods</Link>.
</p>
</>
)}
{step === 3 && (
<p>Por último solamente te queda copiar la dirección del servidor e introducirla en el juego para conectarte y jugar :D
</p>
)}
</div>
{/* —— Footer con el hr + botones ————————————————— */}
<div className="card-footer mt-auto d-flex flex-column align-items-center gap-2">
<hr className="minecraft-hr w-100" />
{step === 1 && (
<div className="d-flex flex-column gap-2">
<button onClick={() => { window.open("https://minecraft.net/", "_blank"); }} className="minecraft-btn">Comprar Minecraft</button>
<button onClick={() => { window.open("/files/miarmacraft/SKLauncher.exe", "_blank"); }} className="minecraft-btn danger">Descargar SKLauncher</button>
</div>
)}
{step === 2 && (
<>
<Link to="https://miarma.net/files/miarmacraft/MiarmaPack.zip" className="minecraft-btn">
Descargar Modpack
</Link>
</>
)}
{step === 3 && (
<>
<button
onClick={() => { copiarIP('V'); setModalShown(true); }}
className="minecraft-btn"
>
IP Vanilla++
</button>
<button
onClick={() => { copiarIP('F'); setModalShown(true); }}
className="minecraft-btn"
>
IP Forge
</button>
</>
)}
</div>
</div>
</Col>
))}
</Row>
<Modal show={modalShown} onHide={() => setModalShown(false)}>
<Modal.Body className="text-center">
<h1>IP COPIADA</h1>
<p>Nos vemos dentro del server.</p>
<button onClick={() => setModalShown(false)} className="minecraft-btn">
Cerrar
</button>
</Modal.Body>
</Modal>
</ContentWrapper>
</CustomContainer>
);
};
export default Inicio;

View File

@@ -0,0 +1,9 @@
import Building from "@/components/layout/Building";
const Jugadores = () => {
return (
<Building />
);
}
export default Jugadores;

View File

@@ -0,0 +1,11 @@
import LoginForm from "@/components/auth/LoginForm";
const Login = () => {
return (
<div className="m-0 p-0 my-5">
<LoginForm />
</div>
);
}
export default Login;

View File

@@ -0,0 +1,166 @@
import { useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { useConfig } from '@/hooks/useConfig';
import { DataProvider } from '@/context/DataContext';
import { useDataContext } from '@/hooks/useDataContext';
import CustomContainer from '@/components/layout/CustomContainer';
import ContentWrapper from '@/components/layout/ContentWrapper';
import LoadingIcon from '@/components/util/LoadingIcon';
import CustomModal from '@/components/modals/CustomModal';
import ModListByDate from '@/components/mods/ModListByDate';
import Mod from '@/components/mods/Mod';
import { errorParser } from '@/util/parsers/errorParser';
const Mods = () => {
const { config, configLoading } = useConfig();
if (configLoading) return <LoadingIcon />;
const reqConfig = {
baseUrl: `${config.apiConfig.baseRawUrl}${config.apiConfig.endpoints.mods.all}`,
params: {
_sort: 'created_at',
_order: 'desc',
},
};
return (
<DataProvider config={reqConfig}>
<ModsContent reqConfig={reqConfig} />
</DataProvider>
);
};
const ModsContent = ({ reqConfig }) => {
const { data, dataLoading, dataError, postData, putData, deleteData } = useDataContext();
const [tempMod, setTempMod] = useState(null);
const [error, setError] = useState(null);
const [deleteTargetId, setDeleteTargetId] = useState(null);
const [showModModal, setShowModModal] = useState(false);
const fileRef = useRef();
const handleCreate = () => {
setTempMod({ mod_id: null, name: '', url: '', status: 1 });
setShowModModal(true);
};
const handleCancelCreate = () => {
setTempMod(null);
setShowModModal(false);
setError(null);
};
const handleCreateSubmit = async (nuevo) => {
try {
const file = fileRef.current?.getSelectedFiles?.()[0];
if (!file) throw new Error("Falta el archivo .jar");
const formData = new FormData();
formData.append('file', file);
formData.append('data', JSON.stringify(nuevo));
await postData(reqConfig.baseUrl, formData);
setTempMod(null);
setShowModModal(false);
setError(null);
fileRef.current?.resetSelectedFiles?.();
} catch (err) {
setError(errorParser(err));
}
};
const handleEditSubmit = async (editado, id) => {
try {
await putData(`${reqConfig.baseUrl}/${id}`, editado);
setError(null);
} catch (err) {
setError(errorParser(err));
}
};
const handleDelete = async (id) => {
setDeleteTargetId(id);
};
if (dataLoading) return <LoadingIcon />;
if (dataError) return <p className="text-danger text-center my-5">{dataError}</p>;
return (
<CustomContainer>
<ContentWrapper>
<div className="m-0 p-0 gap-2 mb-3 d-flex">
<button className="minecraft-btn" onClick={handleCreate}>Nuevo mod</button>
<button
className='minecraft-btn'
onClick={() => { window.open("/files/miarmacraft/MiarmaPack.zip", "_blank"); }}
>
Descargar modpack
</button>
</div>
<ModListByDate
mods={data}
onUpdate={handleEditSubmit}
onDelete={handleDelete}
onClearError={() => setError(null)}
/>
<CustomModal
show={showModModal}
onClose={handleCancelCreate}
>
<div className="p-4">
{error && (
<div className="alert alert-danger mb-3" role="alert">
{error}
</div>
)}
<ul className="list-unstyled m-0 p-0">
<Mod
mod={tempMod}
isNew
fileRef={fileRef}
onCreate={handleCreateSubmit}
onCancel={handleCancelCreate}
onClearError={() => setError(null)}
/>
</ul>
</div>
</CustomModal>
<CustomModal
show={deleteTargetId !== null}
onClose={() => setDeleteTargetId(null)}
>
<p className='p-3'>¿Estás seguro de que quieres eliminar este mod?</p>
<div className="d-flex justify-content-end gap-2 mt-3 p-3">
<button className='minecraft-btn' onClick={() => setDeleteTargetId(null)}>Cancelar</button>
<button
className='minecraft-btn danger'
onClick={async () => {
try {
await deleteData(`${reqConfig.baseUrl}/${deleteTargetId}`);
setDeleteTargetId(null);
} catch (err) {
setError(errorParser(err));
}
}}
>
Eliminar
</button>
</div>
</CustomModal>
</ContentWrapper>
</CustomContainer>
);
};
ModsContent.propTypes = {
reqConfig: PropTypes.shape({
baseUrl: PropTypes.string.isRequired,
params: PropTypes.object.isRequired,
}).isRequired,
};
export default Mods;

View File

@@ -0,0 +1,75 @@
import { useConfig } from "@/hooks/useConfig";
import LoadingIcon from "@/components/util/LoadingIcon";
import { DataProvider } from "@/context/DataContext";
import { useDataContext } from "@/hooks/useDataContext";
import { errorParser } from "@/util/parsers/errorParser";
import PropTypes from "prop-types";
import ContentWrapper from "@/components/layout/ContentWrapper";
import CustomContainer from "@/components/layout/CustomContainer";
import ReactSkinview3d from "react-skinview3d";
import { IdleAnimation } from "skinview3d"
const Profile = () => {
const { config, configLoading } = useConfig();
if (configLoading) return <LoadingIcon />;
const reqConfig = {
baseUrl: `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.players.playerInfo}`,
changePassword: `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.auth.changePassword}`,
params: {}
};
return (
<DataProvider config={reqConfig}>
<ProfileContent reqConfig={reqConfig} />
</DataProvider>
);
};
const ProfileContent = ({ reqConfig }) => {
const { data, dataLoading, dataError } = useDataContext();
if (dataLoading) return <LoadingIcon />;
if (dataError) return <div className="alert alert-danger">{errorParser(dataError)}</div>;
const handleChangePassword = async (e) => {
}
return (
<CustomContainer>
<ContentWrapper>
<div className="minecraft-card not-animated row">
<h1 className="header col-12 mb-4">{data.user_name}</h1>
<ReactSkinview3d
className="col-4"
skinUrl={`https://mineskin.eu/skin/${data.user_name}`}
width={250}
height={500}
onReady={({viewer}) => {
viewer.animation = new IdleAnimation();
viewer.autoRotate = true;
}}
/>
<div className="col-8">
<form onSubmit={handleChangePassword}>
<label>Antigua contraseña:</label>
<input disabled name="latestPass" type="text" className="minecraft-input mb-4" />
<label>Nueva contraseña:</label>
<input disabled name="newPass" type="text" className="minecraft-input mb-4" />
<label>Confirmar contraseña:</label>
<input disabled name="newPassConfirm" type="text" className="minecraft-input mb-4" />
<button disabled type="submit" className="minecraft-btn">Cambiar</button>
</form>
</div>
</div>
</ContentWrapper>
</CustomContainer>
);
};
ProfileContent.propTypes = {
reqConfig: PropTypes.object
};
export default Profile;

View File

@@ -0,0 +1,105 @@
import { useState, useRef, useEffect, cloneElement } from 'react';
import { Button } from 'react-bootstrap';
import { AnimatePresence, motion as _motion } from 'framer-motion';
import '@/css/AnimatedDropdown.css';
import PropTypes from 'prop-types';
const AnimatedDropdown = ({
trigger,
icon,
variant = "",
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 px-2 py-2 ${className}`;
return (
<div
className={`position-relative d-inline-block`}
onClick={toggle}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
ref={triggerRef}
>
{triggerElement}
<AnimatePresence>
{actualOpen && (
<_motion.div
ref={dropdownRef}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.15 }}
className={dropdownClasses}
>
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
</_motion.div>
)}
</AnimatePresence>
</div>
);
};
AnimatedDropdown.propTypes = {
trigger: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
icon: PropTypes.node,
variant: PropTypes.string,
className: PropTypes.string,
buttonStyle: PropTypes.string,
show: PropTypes.bool,
onToggle: PropTypes.func,
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func,
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
};
export default AnimatedDropdown;

View File

@@ -0,0 +1,110 @@
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: 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 dropend"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={triggerRef}
>
{triggerElement}
<AnimatePresence>
{actualOpen && (
<_motion.div
ref={dropdownRef}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.15 }}
className={dropdownClasses}
style={{
position: 'absolute',
top: '0',
left: '100%',
zIndex: 1000,
whiteSpace: 'nowrap'
}}
>
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
</_motion.div>
)}
</AnimatePresence>
</div>
);
};
export default AnimatedDropend;

View File

@@ -0,0 +1,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;

8
src/constants.js Normal file
View File

@@ -0,0 +1,8 @@
'use strict';
const CONSTANTS = {
ADMIN_ROLE: 1,
PLAYER_ROLE: 0
}
export { CONSTANTS };

View File

@@ -0,0 +1,98 @@
import { useState, useEffect, createContext } from "react";
import createAxiosInstance from "@/api/axiosInstance";
import { useConfig } from "@/hooks/useConfig";
export const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const axios = createAxiosInstance();
const { config } = useConfig();
const [user, setUser] = useState(() => JSON.parse(localStorage.getItem("user")) || null);
const [token, setToken] = useState(() => localStorage.getItem("token"));
const [authStatus, setAuthStatus] = useState("checking");
const [error, setError] = useState(null);
useEffect(() => {
if (!config) return;
if (!token) {
setAuthStatus("unauthenticated");
return;
}
const BASE_URL = config.apiConfig.authUrl;
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, loggedUser, tokenTime } = res.data.data;
localStorage.setItem("token", token);
localStorage.setItem("user", JSON.stringify(loggedUser));
localStorage.setItem("tokenTime", tokenTime);
setToken(token);
setUser(loggedUser);
setAuthStatus("authenticated");
} catch (err) {
console.error("Error al iniciar sesión:", err);
let message = "Ha ocurrido un error inesperado.";
if (err.response) {
const { status, data } = err.response;
if (status === 400) {
message = "Usuario o contraseña incorrectos.";
} else if (status === 403) {
message = "Tu cuenta está inactiva o ha sido suspendida.";
} else if (status === 404) {
message = "Usuario no encontrado.";
} else if (data?.message) {
message = data.message;
}
}
setError(message);
throw new Error(message);
}
};
const logout = () => {
localStorage.clear();
setUser(null);
setToken(null);
setAuthStatus("unauthenticated");
};
return (
<AuthContext.Provider value={{ user, token, authStatus, login, logout, error }}>
{children}
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,41 @@
import { createContext, useState, useEffect } from "react";
import PropTypes from "prop-types";
const ConfigContext = createContext();
export const ConfigProvider = ({ children }) => {
const [config, setConfig] = useState(null);
const [configLoading, setLoading] = useState(true);
const [configError, setError] = useState(null);
useEffect(() => {
const fetchConfig = async () => {
try {
const response = import.meta.env.MODE === 'production'
? await fetch(`${import.meta.env.BASE_URL}config/settings.prod.json`)
: await fetch(`${import.meta.env.BASE_URL}config/settings.dev.json`);
if (!response.ok) throw new Error("Error al cargar settings.*.json");
const json = await response.json();
setConfig(json);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchConfig();
}, []);
return (
<ConfigContext.Provider value={{ config, configLoading, configError }}>
{children}
</ConfigContext.Provider>
);
};
ConfigProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export {ConfigContext};

View File

@@ -0,0 +1,23 @@
import { createContext } from "react";
import PropTypes from "prop-types";
import { useData } from "@/hooks/useData";
export const DataContext = createContext();
export const DataProvider = ({ config, children }) => {
const data = useData(config);
return (
<DataContext.Provider value={data}>
{children}
</DataContext.Provider>
);
};
DataProvider.propTypes = {
config: PropTypes.shape({
baseUrl: PropTypes.string.isRequired,
params: PropTypes.object,
}).isRequired,
children: PropTypes.node.isRequired,
};

View File

@@ -0,0 +1,54 @@
/* === Dropdown estilo Minecraft === */
.dropdown-menu {
background-color: var(--background-color) !important;
color: var(--text-color) !important;
font-family: 'MCText Regular';
border: none !important;
padding: var(--spacing-sm);
box-shadow:
inset 6px 6px 0 var(--btn-tertiary-inner-shadow-color),
inset -6px -6px 0 #1d1d1d,
6px 6px 10px rgba(0, 0, 0, 0.5);
/* sombra exterior */
border-radius: 0 !important;
min-width: 200px;
}
.dropdown-menu.show {
display: block;
}
.dropdown-divider {
border: none;
border-top: 2px solid var(--hr-top-color);
border-bottom: 2px solid var(--hr-bottom-color);
}
.dropdown-item {
background-color: var(--background-color);
color: var(--text-color);
font-family: 'MCText Regular';
font-size: 1.4rem;
padding: var(--spacing-sm) var(--spacing-md);
border: none;
cursor: pointer;
transition: background-color 0.1s;
user-select: none;
display: block;
}
.dropdown-item:hover {
background-color: var(--secondary-color) !important;
}
.dropdown-item:active {
background-color: var(--btn-primary-inner-hover-color) !important;
}
.disabled.text-muted,
.dropdown-item.disabled {
color: var(--accent-color);
opacity: 0.5;
cursor: not-allowed;
}

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

View File

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

63
src/css/FileUpload.css Normal file
View File

@@ -0,0 +1,63 @@
/* === Estilo de tarjeta de subida estilo Minecraft === */
.upload-card {
background-color: var(--background-color) !important;
color: var(--text-color);
font-family: 'MCText Regular';
padding: var(--spacing-lg);
cursor: pointer;
border: 3px dashed var(--btn-primary-border-color);
border-radius: 0;
text-align: center;
transition: transform 0.1s, box-shadow 0.1s, border-color 0.1s;
}
.upload-card:hover {
border-color: var(--btn-primary-inner-border-lt-color);
background-color: var(--secondary-color);
}
.upload-card.highlight {
border-color: var(--btn-primary-inner-border-br-color);
background-color: var(--secondary-color);
box-shadow:
inset 6px 6px 0 var(--btn-primary-inner-border-lt-color),
inset -6px -6px 0 var(--btn-primary-inner-border-br-color),
6px 6px 10px rgba(0, 0, 0, 0.5);
}
.upload-card h2 {
font-family: 'MCTitles';
font-size: 1.8rem;
color: var(--accent-color);
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.4);
}
.upload-card p {
font-size: 1.2rem;
}
.upload-card .file-list {
margin-top: var(--spacing-md);
text-align: left;
padding: 0;
list-style: none;
font-family: 'MCText Regular';
font-size: 1.2rem;
}
.upload-card .file-list li {
margin-bottom: var(--spacing-xs);
color: var(--text-color);
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px dashed var(--hr-bottom-color);
padding-bottom: var(--spacing-xs);
}
.upload-card .btn-close {
filter: invert(1);
}

View File

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

@@ -0,0 +1,511 @@
/* ===========================
VARIABLES GLOBALES
=========================== */
:root {
--primary-color: #313233;
--secondary-color: #48494a;
--accent-color: #d0d1d4;
--background-color: #48494a;
--text-color: #ffffff;
--text-dark-color: #000000;
--btn-primary-inner-color: #3c8527;
--btn-primary-inner-hover-color: #2a641c;
--btn-primary-inner-shadow-color: #1d4d13;
--btn-primary-border-color: #1e1e1f;
--btn-primary-inner-border-lt-color: #4f913cbf;
--btn-primary-inner-border-br-color: #639d52;
--btn-danger-inner-color: #c72a2a;
--btn-danger-inner-hover-color: #a61e1e;
--btn-danger-inner-shadow-color: #7a1c1c;
--btn-danger-border-color: #1e1e1f;
--btn-danger-inner-border-lt-color: #c72a2abf;
--btn-danger-inner-border-br-color: #c72a2a;
--btn-tertiary-inner-shadow-color: #58585a;
--hr-top-color: #333334;
--hr-bottom-color: #5a5b5c;
--added-color: #4f913c;
--removed-color: #c72a2a;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 32px;
--spacing-xl: 64px;
}
/* ===========================
FUENTES
=========================== */
@font-face {
font-family: 'MCTitles';
src: url('/fonts/mc-titles.ttf');
}
@font-face {
font-family: 'MCText Regular';
src: url('/fonts/mc-text-regular.otf');
}
@font-face {
font-family: 'MCText Bold';
src: url('/fonts/mc-text-bold.otf');
}
@font-face {
font-family: 'MCText Italic';
src: url('/fonts/mc-text-italic.otf');
}
@font-face {
font-family: 'MCText Bold Italic';
src: url('/fonts/mc-text-bold-italic.otf');
}
/* ===========================
RESET BÁSICO
=========================== */
body {
font-family: 'MCText Regular';
background-image: url("/images/bg_dirt.webp");
color: var(--text-color);
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: 'MCTitles';
font-weight: 700;
color: var(--text-color);
margin-bottom: 0.5em;
}
p {
font-family: 'MCText Regular';
margin-bottom: 1em;
}
/* ===========================
HR (Separador)
=========================== */
.minecraft-hr {
border: none;
border-top: 2px solid var(--hr-top-color);
border-bottom: 2px solid var(--hr-bottom-color);
margin: var(--spacing-md) 0;
}
/* ===========================
CARD
=========================== */
.minecraft-card {
background-color: var(--background-color);
padding: var(--spacing-md);
color: var(--text-color);
font-family: 'MCText Regular';
font-size: 1.4rem;
/* Biseles brutotes */
box-shadow:
inset 6px 6px 0 var(--btn-tertiary-inner-shadow-color),
/* Bisel claro arriba izq */
inset -6px -6px 0 #1d1d1d,
/* Bisel oscuro abajo dcha */
6px 6px 10px rgba(0, 0, 0, 0.5);
/* Sombra exterior gorda */
border-radius: 0;
/* Estilo cuadrado como un bloque */
transition: transform 0.2s, box-shadow 0.2s;
}
.minecraft-card header {
font-family: 'MCTitles';
font-size: 1.6rem;
color: var(--text-color);
margin-bottom: var(--spacing-md);
text-align: center;
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.4);
}
.minecraft-card:hover:not(.not-animated) {
/* Efecto "levantarse" al pasar el ratón */
transform: translateY(-4px);
box-shadow:
inset 6px 6px 0 var(--btn-tertiary-inner-shadow-color),
inset -6px -6px 0 #1d1d1d,
10px 10px 20px rgba(0, 0, 0, 0.6);
}
/* ===========================
BUILDING
=========================== */
/* Construcción: imagen de mina y pico */
.construction-img {
box-shadow:
inset 4px 4px 0 var(--btn-tertiary-inner-shadow-color),
inset -4px -4px 0 #1d1d1d !important;
}
/* Ajustes en la card para que no quede demasiado apretada */
.minecraft-card.py-5 {
padding-top: var(--spacing-lg);
padding-bottom: var(--spacing-lg);
}
/* ===========================
INPUT
=========================== */
.minecraft-input {
background-color: var(--primary-color);
border: 3px solid var(--btn-primary-border-color);
color: var(--text-color);
font-family: 'MCText Regular';
font-size: 1.4rem;
height: 40px;
padding: var(--spacing-sm);
width: 100%;
}
.minecraft-input:focus {
outline: none;
background-color: var(--secondary-color);
}
.minecraft-select {
background-color: var(--primary-color);
border: 3px solid var(--btn-primary-border-color);
color: var(--text-color);
font-family: 'MCText Regular';
font-size: 1.4rem;
height: 40px;
width: 100%;
appearance: none;
background-image: url("/icons/down_arrow.svg");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 1rem;
}
.minecraft-select:focus {
outline: none;
background-color: var(--secondary-color);
}
.minecraft-select>option {
appearance: none;
color: var(--text-color);
font-family: 'MCText Regular';
font-size: 1.4rem;
}
/* ===========================
CHECKBOX
=========================== */
.minecraft-checkbox-wrapper {
display: flex;
align-items: center;
cursor: pointer;
gap: var(--spacing-sm);
user-select: none;
font-family: 'MCText Regular';
}
.minecraft-checkbox {
width: 24px;
height: 24px;
background-color: var(--primary-color);
border: 3px solid var(--btn-primary-border-color);
box-shadow:
inset 4px 4px var(--btn-tertiary-inner-shadow-color),
inset -4px -4px #1d1d1d;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.1s;
font-size: 18px;
line-height: 1;
color: var(--accent-color);
overflow: hidden;
}
.pixel-icon {
width: 24px;
height: 24px;
object-fit: contain;
pointer-events: none;
color: white;
}
.pixel-icon.big {
width: 40px;
height: 40px;
}
.minecraft-checkbox.checked {
background-color: var(--btn-primary-inner-color);
box-shadow:
inset 4px 4px var(--btn-primary-inner-border-lt-color),
inset -4px -4px var(--btn-primary-inner-border-br-color);
border-color: var(--btn-primary-border-color);
}
.minecraft-checkbox-label {
color: var(--text-color);
}
/* ===========================
BOTÓN (General)
=========================== */
.minecraft-btn {
background-color: var(--btn-primary-inner-color);
border: 3px solid var(--btn-primary-border-color);
box-shadow:
inset 0 -6px var(--btn-primary-inner-shadow-color),
inset 3px 3px var(--btn-primary-inner-border-lt-color),
inset -3px -9px var(--btn-primary-inner-border-br-color);
color: var(--text-color);
font-family: 'MCTitles';
font-size: 1.2em;
padding: 8px 16px;
text-decoration: none;
cursor: pointer;
user-select: none;
transition: background-color 0.2s;
}
.minecraft-btn.danger {
background-color: var(--btn-danger-inner-color);
border: 3px solid var(--btn-danger-border-color);
box-shadow:
inset 0 -6px var(--btn-danger-inner-shadow-color),
inset 3px 3px var(--btn-danger-inner-border-lt-color),
inset -3px -9px var(--btn-danger-inner-border-br-color);
color: var(--text-color);
font-family: 'MCTitles';
font-size: 1.2em;
padding: 8px 16px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s;
}
button[disabled].minecraft-btn {
background-color: var(--btn-primary-inner-color);
border: 3px solid var(--btn-primary-border-color);
box-shadow:
inset 0 -6px var(--btn-primary-inner-shadow-color),
inset 3px 3px var(--btn-primary-inner-border-lt-color),
inset -3px -9px var(--btn-primary-inner-border-br-color);
color: var(--bs-dark);
font-family: 'MCTitles';
font-size: 1.2em;
padding: 8px 16px;
cursor: not-allowed;
user-select: none;
}
.minecraft-btn:hover {
background-color: var(--btn-primary-inner-hover-color);
}
.minecraft-btn.danger:hover {
background-color: var(--btn-danger-inner-hover-color);
}
/* ===========================
MODAL ESTILO MINECRAFT
=========================== */
.modal-content {
background-color: var(--background-color) !important;
border: none !important;
border-radius: 0 !important;
box-shadow:
inset 6px 6px 0 var(--btn-tertiary-inner-shadow-color),
inset -6px -6px 0 #1d1d1d,
6px 6px 10px rgba(0, 0, 0, 0.5) !important;
font-family: 'MCText Regular';
color: var(--text-color);
}
/* Header */
.modal-content .modal-header {
padding: var(--spacing-md) var(--spacing-lg) !important;
background-color: var(--primary-color) !important;
border-bottom: 2px solid var(--hr-top-color) !important;
border-radius: 0 !important;
box-shadow:
inset 0 -2px 0 var(--hr-bottom-color) !important;
}
/* Título */
.modal-title {
font-family: 'MCTitles' !important;
font-size: 1.6rem !important;
color: var(--text-color) !important;
}
/* Botón de cerrar */
.btn-close {
background: none !important;
border: none !important;
font-family: 'MCTitles' !important;
font-size: 1.2rem !important;
line-height: 1 !important;
opacity: 1 !important;
filter: invert(1);
}
.btn-close:hover {
color: var(--btn-primary-inner-hover-color) !important;
}
/* Body */
.modal-body {
padding: var(--spacing-md) var(--spacing-lg) !important;
font-family: 'MCText Regular' !important;
font-size: 1.4rem !important;
text-align: center !important;
}
/* Si usas <Modal.Footer> */
.modal-footer {
padding: var(--spacing-md) var(--spacing-lg) !important;
border-top: 2px solid var(--hr-top-color) !important;
box-shadow:
inset 0 2px 0 var(--hr-bottom-color) !important;
justify-content: center;
background-color: var(--primary-color) !important;
}
/* Sobrescribir botones de footer (si los hubiera) */
.modal-footer .btn {
/* asume que ya tienes .minecraft-btn */
all: unset;
}
/* ===========================
FOOTER ESTILO MINECRAFT
=========================== */
.minecraft-footer {
background-color: var(--primary-color);
padding: var(--spacing-lg) var(--spacing-md);
color: var(--text-color);
font-family: 'MCText Regular';
text-align: center;
/* Igual que el nav pero en la parte superior */
border-top: 4px solid var(--secondary-color);
box-shadow: inset 0 6px var(--btn-tertiary-inner-shadow-color);
border-radius: 0;
}
.minecraft-footer .footer-content p {
margin: 0 0 var(--spacing-sm);
font-size: 1.2rem;
}
.minecraft-footer .footer-links {
display: flex;
justify-content: center;
gap: var(--spacing-md);
flex-wrap: wrap;
}
/* ===========================
HEADER VISUAL
=========================== */
.header {
background-color: var(--primary-color);
border-bottom: 4px solid var(--secondary-color);
box-shadow: inset 0 -6px var(--btn-tertiary-inner-shadow-color);
padding: var(--spacing-md) var(--spacing-lg);
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.4);
}
.header-logo {
font-size: 3.0rem;
font-family: 'MCTitles';
color: var(--accent-color);
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.5);
user-select: none;
}
/* Botón de hamburguesa */
.menu-toggle {
background: none;
border: none;
font-size: 2rem;
color: var(--accent-color);
cursor: pointer;
user-select: none;
}
/* Navegación Desktop */
.header-nav a.nav-link {
font-family: 'MCText Regular';
font-size: 1.4rem;
color: var(--text-color);
text-decoration: none;
position: relative;
}
.header-nav a.nav-link:hover {
color: var(--accent-color);
}
.header-nav a.nav-link::after {
content: '';
position: absolute;
width: 100%;
height: 2px;
background-color: var(--accent-color);
bottom: -4px;
left: 0;
transform: scaleX(0);
transform-origin: bottom right;
transition: transform 0.25s ease-out;
}
.header-nav a.nav-link:hover::after {
transform: scaleX(1);
transform-origin: bottom left;
}
/* Menú Mobile */
.header-nav-mobile {
background-color: var(--primary-color);
border-top: 3px solid var(--secondary-color);
padding: var(--spacing-md) 0;
width: 100%;
text-align: center;
}
.header-nav-mobile a.nav-link {
font-family: 'MCText Regular';
font-size: 1.4rem;
color: var(--text-color);
text-decoration: none;
position: relative;
}
/* Botones dentro del menú móvil */
.header-nav-mobile .minecraft-form-btn {
width: 80%;
margin: var(--spacing-sm) auto;
}

4
src/hooks/useAuth.js Normal file
View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { AuthContext } from "../context/AuthContext";
export const useAuth = () => useContext(AuthContext);

4
src/hooks/useConfig.js Normal file
View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { ConfigContext } from "../context/ConfigContext.jsx";
export const useConfig = () => useContext(ConfigContext);

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

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

View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { DataContext } from "../context/DataContext";
export const useDataContext = () => useContext(DataContext);

View File

@@ -0,0 +1,48 @@
import { useState, useRef, useMemo } from 'react';
export const usePaginatedList = ({
data,
pageSize = 10,
filterFn = () => true,
searchFn = () => true,
sortFn = null,
initialFilters = {}
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [filters, setFilters] = useState(initialFilters);
const [creatingItem, setCreatingItem] = useState(false);
const [tempItem, setTempItem] = useState(null);
const isSearching = searchTerm.trim() !== "";
const isFiltering = Object.keys(filters).some(k => filters[k] === false);
const usingSearchOrFilters = isSearching || isFiltering;
const filteredData = useMemo(() => {
if (!data) return [];
let result = data
.filter((item) => filterFn(item, filters))
.filter((item) => searchFn(item, searchTerm));
if (sortFn) {
result = [...result].sort(sortFn); // 👈 Ordena si hay sortFn
}
return result;
}, [data, filterFn, filters, searchFn, searchTerm, sortFn]);
return {
paginated: filteredData.slice(0, pageSize),
filtered: filteredData,
searchTerm,
setSearchTerm,
filters,
setFilters,
loaderRef: useRef(), // opcional si tu PaginatedCardGrid lo espera
loading: false,
hasMore: false,
creatingItem,
setCreatingItem,
tempItem,
setTempItem,
isUsingFilters: usingSearchOrFilters,
resetPagination: () => { } // ya no es necesario pero por compat
};
};

View File

@@ -0,0 +1,103 @@
import { useEffect, useState } from "react";
import { parseJwt } from "@/util/tokenUtils.js";
import NotificationModal from "@/components/modals/NotificationModal.jsx";
import axios from "axios";
import { useAuth } from "./useAuth.js";
import { useConfig } from "./useConfig.js";
const useSessionRenewal = () => {
const { logout } = useAuth();
const { config } = useConfig();
const [showModal, setShowModal] = useState(false);
const [alreadyWarned, setAlreadyWarned] = useState(false);
useEffect(() => {
const interval = setInterval(() => {
const token = localStorage.getItem("token");
const decoded = parseJwt(token);
if (!token || !decoded?.exp) return;
const now = Date.now();
const expTime = decoded.exp * 1000;
const timeLeft = expTime - now;
if (timeLeft <= 60000 && timeLeft > 0 && !alreadyWarned) {
setShowModal(true);
setAlreadyWarned(true);
}
if (timeLeft <= 0) {
clearInterval(interval);
logout();
}
}, 10000); // revisa cada 10 segundos
return () => clearInterval(interval);
}, [alreadyWarned, logout]);
const handleRenew = async () => {
const token = localStorage.getItem("token");
const decoded = parseJwt(token);
const now = Date.now();
const expTime = decoded?.exp * 1000;
if (!token || !decoded || now > expTime) {
logout();
return;
}
try {
const response = await axios.get(
`${config.apiConfig.baseUrl}${config.apiConfig.endpoints.auth.refreshToken}`,
null,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const newToken = response.data.data.token;
localStorage.setItem("token", newToken);
setShowModal(false);
setAlreadyWarned(false);
} catch (err) {
console.error("Error renovando sesión:", err);
logout();
}
};
const modal = showModal && (
<NotificationModal
show={true}
onClose={() => {
setShowModal(false);
logout();
}}
title="¿Quieres seguir conectado?"
message="Tu sesión está a punto de expirar. ¿Quieres renovarla 1 hora más?"
variant="info"
buttons={[
{
label: "Renovar sesión",
variant: "success",
onClick: handleRenew,
},
{
label: "Cerrar sesión",
variant: "danger",
onClick: () => {
logout();
setShowModal(false);
},
},
]}
/>
);
return { modal };
};
export default useSessionRenewal;

185
src/icons.jsx Normal file
View File

@@ -0,0 +1,185 @@
const Icons = {
Menu:
<svg
className='pixel-icon big'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M4 6h16v2H4V6zm0 5h16v2H4v-2zm16 5H4v2h16v-2z" fill="currentColor" />
</svg>,
Close:
<svg
className='pixel-icon big'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M5 5h2v2H5V5zm4 4H7V7h2v2zm2 2H9V9h2v2zm2 0h-2v2H9v2H7v2H5v2h2v-2h2v-2h2v-2h2v2h2v2h2v2h2v-2h-2v-2h-2v-2h-2v-2zm2-2v2h-2V9h2zm2-2v2h-2V7h2zm0 0V5h2v2h-2z" fill="currentColor" />
</svg>,
Eye:
<svg
className='pixel-icon'
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M8 6h8v2H8V6zm-4 4V8h4v2H4zm-2 2v-2h2v2H2zm0 2v-2H0v2h2zm2 2H2v-2h2v2zm4 2H4v-2h4v2zm8 0v2H8v-2h8zm4-2v2h-4v-2h4zm2-2v2h-2v-2h2zm0-2h2v2h-2v-2zm-2-2h2v2h-2v-2zm0 0V8h-4v2h4zm-10 1h4v4h-4v-4z" fill="currentColor" />
</svg>,
EyeSlash:
<svg
className='pixel-icon'
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M0 7h2v2H0V7zm4 4H2V9h2v2zm4 2v-2H4v2H2v2h2v-2h4zm8 0H8v2H6v2h2v-2h8v2h2v-2h-2v-2zm4-2h-4v2h4v2h2v-2h-2v-2zm2-2v2h-2V9h2zm0 0V7h2v2h-2z" fill="currentColor" />
</svg>,
CheckboxOn:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M5 3H3v18h18V3H5zm0 2h14v14H5V5zm4 7H7v2h2v2h2v-2h2v-2h2v-2h2V8h-2v2h-2v2h-2v2H9v-2z" fill="currentColor" />
</svg>,
CheckboxOff:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M3 3h18v18H3V3zm16 16V5H5v14h14z" fill="currentColor" />
</svg>,
Home:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M14 2h-4v2H8v2H6v2H4v2H2v2h2v10h7v-6h2v6h7V12h2v-2h-2V8h-2V6h-2V4h-2V2zm0 2v2h2v2h2v2h2v2h-2v8h-3v-6H9v6H6v-8H4v-2h2V8h2V6h2V4h4z" fill="currentColor" />
</svg>,
AddGrid:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M3 3h8v8H3V3zm6 6V5H5v4h4zm9 4h-2v3h-3v2h3v3h2v-3h3v-2h-3v-3zM15 3h6v8h-8V3h2zm4 6V5h-4v4h4zM5 13h6v8H3v-8h2zm4 6v-4H5v4h4z" fill="currentColor" />
</svg>,
Users:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M11 0H5v2H3v6h2v2h6V8H5V2h6V0zm0 2h2v6h-2V2zM0 14h2v4h12v2H0v-6zm2 0h12v-2H2v2zm14 0h-2v6h2v-6zM15 0h4v2h-4V0zm4 8h-4v2h4V8zm0-6h2v6h-2V2zm5 12h-2v4h-4v2h6v-6zm-6-2h4v2h-4v-2z" fill="currentColor" />
</svg>,
Login:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M5 3H3v4h2V5h14v14H5v-2H3v4h18V3H5zm12 8h-2V9h-2V7h-2v2h2v2H3v2h10v2h-2v2h2v-2h2v-2h2v-2z" fill="currentColor" />
</svg>,
Logout:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M5 3h16v4h-2V5H5v14h14v-2h2v4H3V3h2zm16 8h-2V9h-2V7h-2v2h2v2H7v2h10v2h-2v2h2v-2h2v-2h2v-2z" fill="currentColor" />
</svg>,
Download:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M13 17V3h-2v10H9v-2H7v2h2v2h2v2h2zm8 2v-4h-2v4H5v-4H3v6h18v-2zm-8-6v2h2v-2h2v-2h-2v2h-2z" fill="currentColor" />
</svg>,
FilePlus:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
style={{ color: 'var(--added-color)' }}
>
<path d="M19 22h-7v-2h7V10h-6V4H5v8H3V2h12v2h2v2h2v2h2v14h-2zM17 6h-2v2h2V6zM8 19h3v-2H8v-3H6v3H3v2h3v3h2v-3z" fill="currentColor" />
</svg>,
Edit:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M18 2h-2v2h2V2zM4 4h6v2H4v14h14v-6h2v8H2V4h2zm4 8H6v6h6v-2h2v-2h-2v2H8v-4zm4-2h-2v2H8v-2h2V8h2V6h2v2h-2v2zm2-6h2v2h-2V4zm4 0h2v2h2v2h-2v2h-2v2h-2v-2h2V8h2V6h-2V4zm-4 8h2v2h-2v-2z" fill="currentColor" />
</svg>,
Trash:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
style={{ color: 'var(--removed-color)' }}
>
<path d="M16 2v4h6v2h-2v14H4V8H2V6h6V2h8zm-2 2h-4v2h4V4zm0 4H6v12h12V8h-4zm-5 2h2v8H9v-8zm6 0h-2v8h2v-8z" fill="currentColor" />
</svg>,
Dots:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M15 1v6H9V1h6zm-2 2h-2v2h2V3zm2 6v6H9V9h6zm-2 2h-2v2h2v-2zm2 6v6H9v-6h6zm-2 2h-2v2h2v-2z" fill="currentColor" />
</svg>,
Save:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M4 2h14v2H4v16h2v-6h12v6h2V6h2v16H2V2h2zm4 18h8v-4H8v4zM20 6h-2V4h2v2zM6 6h9v4H6V6z" fill="currentColor" />
</svg>,
Cancel:
<svg
className='pixel-icon'
fill="none"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
>
<path d="M5 5h2v2H5V5zm4 4H7V7h2v2zm2 2H9V9h2v2zm2 0h-2v2H9v2H7v2H5v2h2v-2h2v-2h2v-2h2v2h2v2h2v2h2v-2h-2v-2h-2v-2h-2v-2zm2-2v2h-2V9h2zm2-2v2h-2V7h2zm0 0V5h2v2h-2z" fill="currentColor" />
</svg>,
}
export default Icons;

24
src/main.jsx Normal file
View File

@@ -0,0 +1,24 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from '@/components/App.jsx'
import { BrowserRouter } from 'react-router-dom'
import { ConfigProvider } from '@/context/ConfigContext.jsx'
import { AuthProvider } from '@/context/AuthContext.jsx'
import '@/css/index.css'
import '@fortawesome/fontawesome-free/css/all.min.css'
import '@fortawesome/fontawesome-free/js/all.min.js'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
createRoot(document.getElementById('root')).render(
<StrictMode>
<ConfigProvider>
<AuthProvider>
<BrowserRouter basename='/miarmacraft/'>
<App />
</BrowserRouter>
</AuthProvider>
</ConfigProvider>
</StrictMode>
)

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

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

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

@@ -0,0 +1,48 @@
'use strict';
const CONSTANTS = {
// Roles
ROLE_USER: 0,
ROLE_ADMIN: 1,
ROLE_DEV: 2,
// Tipos de usuario en huertos
TYPE_WAIT_LIST: 0,
TYPE_MEMBER: 1,
TYPE_WITH_GREENHOUSE: 2,
TYPE_COLLABORATOR: 3,
TYPE_DEVELOPER: 4,
TYPE_SUBSIDY: 5,
// Estado de usuario
STATUS_INACTIVE: 0,
STATUS_ACTIVE: 1,
// Tipos de solicitud
REQUEST_TYPE_REGISTER: 0,
REQUEST_TYPE_UNREGISTER: 1,
REQUEST_TYPE_ADD_COLLABORATOR: 2,
REQUEST_TYPE_REMOVE_COLLABORATOR: 3,
REQUEST_TYPE_ADD_GREENHOUSE: 4,
REQUEST_TYPE_REMOVE_GREENHOUSE: 5,
// Estado de solicitud
REQUEST_PENDING: 0,
REQUEST_APPROVED: 1,
REQUEST_REJECTED: 2,
// Tipo de pago
PAYMENT_TYPE_BANK: 0,
PAYMENT_TYPE_CASH: 1,
// Frecuencia de pago
PAYMENT_FREQUENCY_BIYEARLY: 0,
PAYMENT_FREQUENCY_YEARLY: 1,
// Prioridad de anuncio
ANNOUNCE_PRIORITY_LOW: 0,
ANNOUNCE_PRIORITY_MEDIUM: 1,
ANNOUNCE_PRIORITY_HIGH: 2,
};
export { CONSTANTS };

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

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

View File

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

View File

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

View File

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

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

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

29
vite.config.js Normal file
View File

@@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: "localhost",
port: 3000,
},
base: '/miarmacraft/',
resolve: {
alias: {
'@/': '/src/',
},
},
build: {
chunkSizeWarningLimit: 1000, // para no ver el warning
rollupOptions: {
output: {
manualChunks: {
react: ['react', 'react-dom'],
router: ['react-router-dom'],
motion: ['framer-motion'],
axios: ['axios'],
}
}
}
}
})