diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..238d2e4
--- /dev/null
+++ b/eslint.config.js
@@ -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 },
+ ],
+ },
+ },
+]
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..fe40c08
--- /dev/null
+++ b/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ MiarmaCraft
+
+
+
+
+
+
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
new file mode 100644
index 0000000..bcad554
--- /dev/null
+++ b/package.json
@@ -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"
+ }
+}
diff --git a/public/config/settings.dev.json b/public/config/settings.dev.json
new file mode 100644
index 0000000..461b9ba
--- /dev/null
+++ b/public/config/settings.dev.json
@@ -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"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/public/config/settings.prod.json b/public/config/settings.prod.json
new file mode 100644
index 0000000..461b9ba
--- /dev/null
+++ b/public/config/settings.prod.json
@@ -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"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/public/fonts/mc-text-bold-italic.otf b/public/fonts/mc-text-bold-italic.otf
new file mode 100644
index 0000000..1f74f38
Binary files /dev/null and b/public/fonts/mc-text-bold-italic.otf differ
diff --git a/public/fonts/mc-text-bold.otf b/public/fonts/mc-text-bold.otf
new file mode 100644
index 0000000..87b124c
Binary files /dev/null and b/public/fonts/mc-text-bold.otf differ
diff --git a/public/fonts/mc-text-italic.otf b/public/fonts/mc-text-italic.otf
new file mode 100644
index 0000000..6801bd8
Binary files /dev/null and b/public/fonts/mc-text-italic.otf differ
diff --git a/public/fonts/mc-text-regular.otf b/public/fonts/mc-text-regular.otf
new file mode 100644
index 0000000..54f08ad
Binary files /dev/null and b/public/fonts/mc-text-regular.otf differ
diff --git a/public/fonts/mc-titles.ttf b/public/fonts/mc-titles.ttf
new file mode 100644
index 0000000..c1be72b
Binary files /dev/null and b/public/fonts/mc-titles.ttf differ
diff --git a/public/images/background.png b/public/images/background.png
new file mode 100644
index 0000000..0271fbb
Binary files /dev/null and b/public/images/background.png differ
diff --git a/public/images/bg_dirt.webp b/public/images/bg_dirt.webp
new file mode 100644
index 0000000..4195a1f
Binary files /dev/null and b/public/images/bg_dirt.webp differ
diff --git a/public/images/building.webp b/public/images/building.webp
new file mode 100644
index 0000000..52e60a1
Binary files /dev/null and b/public/images/building.webp differ
diff --git a/public/images/favicon.ico b/public/images/favicon.ico
new file mode 100644
index 0000000..0695e65
Binary files /dev/null and b/public/images/favicon.ico differ
diff --git a/public/images/miarmacraft.svg b/public/images/miarmacraft.svg
new file mode 100644
index 0000000..b6ac668
--- /dev/null
+++ b/public/images/miarmacraft.svg
@@ -0,0 +1,429 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/images/miarmacraft_mods.svg b/public/images/miarmacraft_mods.svg
new file mode 100644
index 0000000..5cb4692
--- /dev/null
+++ b/public/images/miarmacraft_mods.svg
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/images/sign.jpg b/public/images/sign.jpg
new file mode 100644
index 0000000..39fb6c2
Binary files /dev/null and b/public/images/sign.jpg differ
diff --git a/public/images/title.gif b/public/images/title.gif
new file mode 100644
index 0000000..75ece50
Binary files /dev/null and b/public/images/title.gif differ
diff --git a/public/images/title.png b/public/images/title.png
new file mode 100644
index 0000000..da00872
Binary files /dev/null and b/public/images/title.png differ
diff --git a/public/privacy.txt b/public/privacy.txt
new file mode 100644
index 0000000..f3b4296
--- /dev/null
+++ b/public/privacy.txt
@@ -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.
\ 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/App.jsx b/src/components/App.jsx
new file mode 100644
index 0000000..3dcbe7c
--- /dev/null
+++ b/src/components/App.jsx
@@ -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 (
+ <>
+
+
+ } />
+
+
+
+ } />
+
+
+
+ } />
+ } />
+
+
+
+ } />
+
+
+ {routesWithFooter.includes(location) ? : null}
+ >
+ );
+}
+
+export default App;
diff --git a/src/components/auth/CustomCheckbox.jsx b/src/components/auth/CustomCheckbox.jsx
new file mode 100644
index 0000000..012f0d8
--- /dev/null
+++ b/src/components/auth/CustomCheckbox.jsx
@@ -0,0 +1,25 @@
+import PropTypes from "prop-types";
+import Icons from "@/icons.jsx";
+
+const CustomCheckbox = ({ checked, onChange, label }) => {
+ const handleToggle = () => {
+ onChange(!checked);
+ };
+
+ return (
+
+
+ {checked ? Icons.CheckboxOn : Icons.CheckboxOff}
+
+ {label &&
{label} }
+
+ );
+};
+
+CustomCheckbox.propTypes = {
+ checked: PropTypes.bool.isRequired,
+ onChange: PropTypes.func.isRequired,
+ label: PropTypes.string,
+};
+
+export default CustomCheckbox;
diff --git a/src/components/auth/IfAuthenticated.jsx b/src/components/auth/IfAuthenticated.jsx
new file mode 100644
index 0000000..9504174
--- /dev/null
+++ b/src/components/auth/IfAuthenticated.jsx
@@ -0,0 +1,8 @@
+import { useAuth } from "@/hooks/useAuth.js";
+
+const IfAuthenticated = ({ children }) => {
+ const { authStatus } = useAuth();
+ return authStatus === "authenticated" ? children : null;
+};
+
+export default IfAuthenticated;
diff --git a/src/components/auth/IfNotAuthenticated.jsx b/src/components/auth/IfNotAuthenticated.jsx
new file mode 100644
index 0000000..53593c1
--- /dev/null
+++ b/src/components/auth/IfNotAuthenticated.jsx
@@ -0,0 +1,8 @@
+import { useAuth } from "@/hooks/useAuth.js";
+
+const IfNotAuthenticated = ({ children }) => {
+ const { authStatus } = useAuth();
+ return authStatus === "unauthenticated" ? children : null;
+};
+
+export default IfNotAuthenticated;
diff --git a/src/components/auth/IfRole.jsx b/src/components/auth/IfRole.jsx
new file mode 100644
index 0000000..336791a
--- /dev/null
+++ b/src/components/auth/IfRole.jsx
@@ -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;
diff --git a/src/components/auth/LoginForm.jsx b/src/components/auth/LoginForm.jsx
new file mode 100644
index 0000000..b35b1ba
--- /dev/null
+++ b/src/components/auth/LoginForm.jsx
@@ -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 (
+
+
+
+
Inicio de sesion
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+ );
+}
+
+LoginForm.propTypes = {
+ emailOrUsername: PropTypes.string,
+ password: PropTypes.string,
+};
+
+export default LoginForm;
\ No newline at end of file
diff --git a/src/components/auth/PasswordInput.jsx b/src/components/auth/PasswordInput.jsx
new file mode 100644
index 0000000..91d4cf2
--- /dev/null
+++ b/src/components/auth/PasswordInput.jsx
@@ -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 (
+
+
+
+
+ {show ? Icons.EyeSlash : Icons.Eye}
+
+
+ );
+};
+
+PasswordInput.propTypes = {
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ name: PropTypes.string,
+ className: PropTypes.string,
+};
+
+export default PasswordInput;
diff --git a/src/components/auth/ProfilePicture.jsx b/src/components/auth/ProfilePicture.jsx
new file mode 100644
index 0000000..2acad16
--- /dev/null
+++ b/src/components/auth/ProfilePicture.jsx
@@ -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 (
+
+
+
+ );
+}
+
+ProfilePicture.propTypes = {
+ userName: PropTypes.string,
+ part: PropTypes.string,
+};
+
+export default ProfilePicture;
\ No newline at end of file
diff --git a/src/components/auth/ProtectedRoute.jsx b/src/components/auth/ProtectedRoute.jsx
new file mode 100644
index 0000000..911d951
--- /dev/null
+++ b/src/components/auth/ProtectedRoute.jsx
@@ -0,0 +1,18 @@
+import { Navigate } from "react-router-dom";
+import { useAuth } from "@/hooks/useAuth.js";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { faSpinner } from "@fortawesome/free-solid-svg-icons";
+
+const ProtectedRoute = ({ minimumRoles, children }) => {
+ const { authStatus } = useAuth();
+
+ if (authStatus === "checking") return ; // o un loader si quieres
+ if (authStatus === "unauthenticated") return ;
+ if (authStatus === "authenticated" && minimumRoles) {
+ const userRole = JSON.parse(localStorage.getItem("user"))?.role;
+ if (!minimumRoles.includes(userRole)) return ;
+ }
+ return children;
+};
+
+export default ProtectedRoute;
diff --git a/src/components/inputs/FileUpload.jsx b/src/components/inputs/FileUpload.jsx
new file mode 100644
index 0000000..8b43aba
--- /dev/null
+++ b/src/components/inputs/FileUpload.jsx
@@ -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 (
+
+
+
📎 Subir archivo
+
+ Arrastra o haz click para seleccionar archivos (Máx. 100MB)
+
+
+ {selectedFiles.length > 0 && (
+
+ {selectedFiles.map((file, idx) => (
+
+ 📄 {file.name}
+ {
+ e.stopPropagation();
+ removeFile(idx);
+ }}
+ />
+
+ ))}
+
+ )}
+
+
+ );
+});
+
+FileUpload.displayName = "FileUpload";
+
+FileUpload.propTypes = {
+ onFilesSelected: PropTypes.func,
+};
+
+export default FileUpload;
diff --git a/src/components/inputs/SearchToolbar.jsx b/src/components/inputs/SearchToolbar.jsx
new file mode 100644
index 0000000..204f582
--- /dev/null
+++ b/src/components/inputs/SearchToolbar.jsx
@@ -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 }) => (
+
+
+
onSearchChange(e.target.value)}
+ />
+
+ {filtersComponent && (
+
}>
+ {filtersComponent}
+
+ )}
+ {onPDF && (
+
+
+
+
+
+ )}
+ {onCreate && (
+
+
+
+
+
+ )}
+
+
+
+);
+
+export default SearchToolbar;
\ No newline at end of file
diff --git a/src/components/inputs/SpanishDateTimePicker.jsx b/src/components/inputs/SpanishDateTimePicker.jsx
new file mode 100644
index 0000000..368762b
--- /dev/null
+++ b/src/components/inputs/SpanishDateTimePicker.jsx
@@ -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 (
+
+ );
+};
+
+export default SpanishDateTimePicker;
diff --git a/src/components/layout/Building.jsx b/src/components/layout/Building.jsx
new file mode 100644
index 0000000..10a24b4
--- /dev/null
+++ b/src/components/layout/Building.jsx
@@ -0,0 +1,24 @@
+import CustomContainer from '@/components/layout/CustomContainer';
+import { Row, Col } from 'react-bootstrap';
+
+const Building = () => {
+ return (
+
+
+
+
+
+
Pagina en construccion
+
Estamos trabajando en ello. ¡Vuelve pronto!
+
+
+
+
+ );
+};
+
+export default Building;
diff --git a/src/components/layout/ContentWrapper.jsx b/src/components/layout/ContentWrapper.jsx
new file mode 100644
index 0000000..8ba9881
--- /dev/null
+++ b/src/components/layout/ContentWrapper.jsx
@@ -0,0 +1,16 @@
+import PropTypes from 'prop-types';
+
+const ContentWrapper = ({ children, row = false }) => {
+ return (
+
+ {children}
+
+ );
+}
+
+ContentWrapper.propTypes = {
+ children: PropTypes.node.isRequired,
+ row: PropTypes.bool,
+}
+
+export default ContentWrapper;
\ No newline at end of file
diff --git a/src/components/layout/CustomCarousel.jsx b/src/components/layout/CustomCarousel.jsx
new file mode 100644
index 0000000..014e839
--- /dev/null
+++ b/src/components/layout/CustomCarousel.jsx
@@ -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 (
+
+
+ {images.map((src, index) => (
+
+
+
+ ))}
+
+
+ );
+};
+
+export default CustomCarousel;
diff --git a/src/components/layout/CustomContainer.jsx b/src/components/layout/CustomContainer.jsx
new file mode 100644
index 0000000..3c71b70
--- /dev/null
+++ b/src/components/layout/CustomContainer.jsx
@@ -0,0 +1,15 @@
+import PropTypes from 'prop-types';
+
+const CustomContainer = ({ children }) => {
+ return (
+
+ {children}
+
+ );
+}
+
+CustomContainer.propTypes = {
+ children: PropTypes.node.isRequired
+}
+
+export default CustomContainer;
\ No newline at end of file
diff --git a/src/components/layout/Footer.jsx b/src/components/layout/Footer.jsx
new file mode 100644
index 0000000..68a9042
--- /dev/null
+++ b/src/components/layout/Footer.jsx
@@ -0,0 +1,20 @@
+import PropTypes from "prop-types";
+
+const Footer = () => (
+
+);
+
+Footer.propTypes = {
+ sticky: PropTypes.bool,
+};
+
+export default Footer;
diff --git a/src/components/layout/Header.jsx b/src/components/layout/Header.jsx
new file mode 100644
index 0000000..e4a5c90
--- /dev/null
+++ b/src/components/layout/Header.jsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {open ? Icons.Close : Icons.Menu}
+
+
+
+
+
+ {Icons.Home}
+ INICIO
+
+
+
+
+
+ {Icons.AddGrid}
+ MODS
+
+
+
+
+
+
+ {Icons.Users}
+ JUGADORES
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Icons.Login}
+ INICIAR SESION
+
+
+
+
+
+
+
+ {Icons.Logout}
+ CERRAR SESION
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default Header;
diff --git a/src/components/layout/PaginatedCardGrid.jsx b/src/components/layout/PaginatedCardGrid.jsx
new file mode 100644
index 0000000..ff95549
--- /dev/null
+++ b/src/components/layout/PaginatedCardGrid.jsx
@@ -0,0 +1,24 @@
+import LoadingIcon from '@/components/util/LoadingIcon';
+
+const PaginatedCardGrid = ({
+ items = [],
+ renderCard,
+ creatingItem = null,
+ renderCreatingCard = null,
+ loaderRef,
+ loading = false
+}) => {
+ return (
+
+ {creatingItem && renderCreatingCard && renderCreatingCard()}
+
+ {items.map((item, i) => renderCard(item, i))}
+
+
+ {loading && }
+
+
+ );
+};
+
+export default PaginatedCardGrid;
diff --git a/src/components/modals/CustomModal.jsx b/src/components/modals/CustomModal.jsx
new file mode 100644
index 0000000..e58da3e
--- /dev/null
+++ b/src/components/modals/CustomModal.jsx
@@ -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 (
+
+ {title && (
+
+ {title}
+
+
+
+
+ )}
+
+ {children}
+
+
+ );
+}
+
+export default CustomModal;
\ No newline at end of file
diff --git a/src/components/modals/NotificationModal.jsx b/src/components/modals/NotificationModal.jsx
new file mode 100644
index 0000000..aef82c9
--- /dev/null
+++ b/src/components/modals/NotificationModal.jsx
@@ -0,0 +1,69 @@
+import PropTypes from 'prop-types';
+import { Modal, Button } from 'react-bootstrap';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import {
+ faCircleCheck,
+ faCircleXmark,
+ faCircleExclamation,
+ faCircleInfo
+} from '@fortawesome/free-solid-svg-icons';
+
+const iconMap = {
+ success: faCircleCheck,
+ danger: faCircleXmark,
+ warning: faCircleExclamation,
+ info: faCircleInfo
+};
+
+const NotificationModal = ({
+ show,
+ onClose,
+ title,
+ message,
+ variant = "info",
+ buttons = [{ label: "Aceptar", variant: "primary", onClick: onClose }]
+}) => {
+ return (
+
+
+
+
+ {title}
+
+
+
+
+ {message}
+
+
+
+ {buttons.map((btn, index) => (
+
+ {btn.label}
+
+ ))}
+
+
+ );
+};
+
+NotificationModal.propTypes = {
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ title: PropTypes.string.isRequired,
+ message: PropTypes.string.isRequired,
+ variant: PropTypes.oneOf(['success', 'danger', 'warning', 'info']),
+ buttons: PropTypes.arrayOf(
+ PropTypes.shape({
+ label: PropTypes.string.isRequired,
+ variant: PropTypes.string,
+ onClick: PropTypes.func
+ })
+ )
+};
+
+export default NotificationModal;
diff --git a/src/components/mods/Mod.jsx b/src/components/mods/Mod.jsx
new file mode 100644
index 0000000..99689ea
--- /dev/null
+++ b/src/components/mods/Mod.jsx
@@ -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 (
+
+
+ handleChange("name", e.target.value)} />
+ handleChange("status", parseInt(e.target.value))}>
+ ➕
+ ➖
+
+
+
+ {/* Solo se muestra cuando editas un mod existente */}
+ {!createMode && (
+
handleChange("url", e.target.value)}
+ />
+ )}
+
+ {createMode &&
}
+
+
+
+ {Icons.Save}
+
+
+ {Icons.Cancel}
+
+
+
+ );
+ }
+
+ // Modo visual normal
+ return (
+
+
+ {isActive ? Icons.FilePlus : Icons.Trash}
+ {mod.name}
+
+
+ {mod.url !== "no" && isActive && (
+
+ {Icons.Download}
+
+ )}
+
+
+ {Icons.Dots}
+
+ }
+ className="end-0"
+ >
+ {({ closeDropdown }) => (
+ <>
+ { handleEdit(); closeDropdown(); }}>
+ {Icons.Edit} Editar
+
+
+ { handleDelete(); closeDropdown(); }}>
+ {Icons.Trash} Eliminar
+
+ >
+ )}
+
+
+
+
+ );
+};
+
+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;
\ No newline at end of file
diff --git a/src/components/mods/ModListByDate.jsx b/src/components/mods/ModListByDate.jsx
new file mode 100644
index 0000000..e118caa
--- /dev/null
+++ b/src/components/mods/ModListByDate.jsx
@@ -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 (
+
+ {sortedDates.map((date) => (
+
+
+ {formatDate(date)}
+
+
+ {modsByDate[date]
+ .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
+ .map((mod) => (
+
+ ))}
+
+
+
+ ))}
+
+ );
+};
+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;
diff --git a/src/components/pages/Inicio.jsx b/src/components/pages/Inicio.jsx
new file mode 100644
index 0000000..277c044
--- /dev/null
+++ b/src/components/pages/Inicio.jsx
@@ -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 (
+
+
+ Pasos para unirse al servidor
+
+
+ {[1, 2, 3].map((step) => (
+
+
+
+ {/* —— Contenido “arriba” ————————————————————— */}
+
+ Paso {step}
+
+
+
+ {step === 1 && (
+ <>
+
Necesitas tener el juego para entrar en el servidor (gracias capitán obvio) así que tienes dos opciones:
+
+
+ Comprarlo en la página oficial.
+ Descargar el launcher SKLauncher (no recomendado).
+
+ >
+ )}
+
+ {step === 2 && (
+ <>
+
+ Para jugar al server Vanilla++ deberás entrar en la versión 1.21.8 vanilla sin más.
+
+
+ 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 lista de mods.
+
+ >
+ )}
+
+ {step === 3 && (
+
Por último solamente te queda copiar la dirección del servidor e introducirla en el juego para conectarte y jugar :D
+
+ )}
+
+
+ {/* —— Footer con el hr + botones ————————————————— */}
+
+
+ {step === 1 && (
+
+ { window.open("https://minecraft.net/", "_blank"); }} className="minecraft-btn">Comprar Minecraft
+ { window.open("/files/miarmacraft/SKLauncher.exe", "_blank"); }} className="minecraft-btn danger">Descargar SKLauncher
+
+ )}
+ {step === 2 && (
+ <>
+
+ Descargar Modpack
+
+ >
+ )}
+ {step === 3 && (
+ <>
+
{ copiarIP('V'); setModalShown(true); }}
+ className="minecraft-btn"
+ >
+ IP Vanilla++
+
+
{ copiarIP('F'); setModalShown(true); }}
+ className="minecraft-btn"
+ >
+ IP Forge
+
+ >
+ )}
+
+
+
+
+ ))}
+
+
+
+ setModalShown(false)}>
+
+ IP COPIADA
+ Nos vemos dentro del server.
+ setModalShown(false)} className="minecraft-btn">
+ Cerrar
+
+
+
+
+
+ );
+};
+
+export default Inicio;
diff --git a/src/components/pages/Jugadores.jsx b/src/components/pages/Jugadores.jsx
new file mode 100644
index 0000000..5d6e74d
--- /dev/null
+++ b/src/components/pages/Jugadores.jsx
@@ -0,0 +1,9 @@
+import Building from "@/components/layout/Building";
+
+const Jugadores = () => {
+ return (
+
+ );
+}
+
+export default Jugadores;
\ No newline at end of file
diff --git a/src/components/pages/Login.jsx b/src/components/pages/Login.jsx
new file mode 100644
index 0000000..d47795e
--- /dev/null
+++ b/src/components/pages/Login.jsx
@@ -0,0 +1,11 @@
+import LoginForm from "@/components/auth/LoginForm";
+
+const Login = () => {
+ return (
+
+
+
+ );
+}
+
+export default Login;
\ No newline at end of file
diff --git a/src/components/pages/Mods.jsx b/src/components/pages/Mods.jsx
new file mode 100644
index 0000000..591e630
--- /dev/null
+++ b/src/components/pages/Mods.jsx
@@ -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 ;
+
+ const reqConfig = {
+ baseUrl: `${config.apiConfig.baseRawUrl}${config.apiConfig.endpoints.mods.all}`,
+ params: {
+ _sort: 'created_at',
+ _order: 'desc',
+ },
+ };
+
+ return (
+
+
+
+ );
+};
+
+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 ;
+ if (dataError) return {dataError}
;
+
+ return (
+
+
+
+ Nuevo mod
+ { window.open("/files/miarmacraft/MiarmaPack.zip", "_blank"); }}
+ >
+ Descargar modpack
+
+
+ setError(null)}
+ />
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ setDeleteTargetId(null)}
+ >
+ ¿Estás seguro de que quieres eliminar este mod?
+
+ setDeleteTargetId(null)}>Cancelar
+ {
+ try {
+ await deleteData(`${reqConfig.baseUrl}/${deleteTargetId}`);
+ setDeleteTargetId(null);
+ } catch (err) {
+ setError(errorParser(err));
+ }
+ }}
+ >
+ Eliminar
+
+
+
+
+
+ );
+};
+
+ModsContent.propTypes = {
+ reqConfig: PropTypes.shape({
+ baseUrl: PropTypes.string.isRequired,
+ params: PropTypes.object.isRequired,
+ }).isRequired,
+};
+
+export default Mods;
\ No newline at end of file
diff --git a/src/components/pages/Profile.jsx b/src/components/pages/Profile.jsx
new file mode 100644
index 0000000..bd153ae
--- /dev/null
+++ b/src/components/pages/Profile.jsx
@@ -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 ;
+
+ const reqConfig = {
+ baseUrl: `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.players.playerInfo}`,
+ changePassword: `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.auth.changePassword}`,
+ params: {}
+ };
+
+ return (
+
+
+
+ );
+};
+
+const ProfileContent = ({ reqConfig }) => {
+ const { data, dataLoading, dataError } = useDataContext();
+ if (dataLoading) return ;
+ if (dataError) return {errorParser(dataError)}
;
+
+ const handleChangePassword = async (e) => {
+
+ }
+
+ return (
+
+
+
+
{data.user_name}
+
{
+ viewer.animation = new IdleAnimation();
+ viewer.autoRotate = true;
+ }}
+ />
+
+
+
+
+
+
+ );
+};
+
+
+ProfileContent.propTypes = {
+ reqConfig: PropTypes.object
+};
+
+export default Profile;
diff --git a/src/components/util/AnimatedDropdown.jsx b/src/components/util/AnimatedDropdown.jsx
new file mode 100644
index 0000000..2c41923
--- /dev/null
+++ b/src/components/util/AnimatedDropdown.jsx
@@ -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 }))
+ : (
+
+ {icon}
+
+ );
+
+ const dropdownClasses = `dropdown-menu show 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}
+
+ )}
+
+
+ );
+};
+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;
diff --git a/src/components/util/AnimatedDropend.jsx b/src/components/util/AnimatedDropend.jsx
new file mode 100644
index 0000000..918f17f
--- /dev/null
+++ b/src/components/util/AnimatedDropend.jsx
@@ -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 }))
+ : (
+
+ {icon}
+
+ );
+
+ 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/util/LoadingIcon.jsx b/src/components/util/LoadingIcon.jsx
new file mode 100644
index 0000000..e877e43
--- /dev/null
+++ b/src/components/util/LoadingIcon.jsx
@@ -0,0 +1,10 @@
+import { faSpinner } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+
+const LoadingIcon = () => {
+ return (
+
+ );
+}
+
+export default LoadingIcon;
\ No newline at end of file
diff --git a/src/constants.js b/src/constants.js
new file mode 100644
index 0000000..6c4f993
--- /dev/null
+++ b/src/constants.js
@@ -0,0 +1,8 @@
+'use strict';
+
+const CONSTANTS = {
+ ADMIN_ROLE: 1,
+ PLAYER_ROLE: 0
+}
+
+export { CONSTANTS };
\ No newline at end of file
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx
new file mode 100644
index 0000000..7fa784d
--- /dev/null
+++ b/src/context/AuthContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
diff --git a/src/context/ConfigContext.jsx b/src/context/ConfigContext.jsx
new file mode 100644
index 0000000..ce8665c
--- /dev/null
+++ b/src/context/ConfigContext.jsx
@@ -0,0 +1,41 @@
+import { createContext, useState, useEffect } from "react";
+import PropTypes from "prop-types";
+
+const ConfigContext = createContext();
+
+export const ConfigProvider = ({ children }) => {
+ const [config, setConfig] = useState(null);
+ const [configLoading, setLoading] = useState(true);
+ const [configError, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchConfig = async () => {
+ try {
+ const response = import.meta.env.MODE === 'production'
+ ? await fetch(`${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 (
+
+ {children}
+
+ );
+};
+
+ConfigProvider.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+export {ConfigContext};
\ No newline at end of file
diff --git a/src/context/DataContext.jsx b/src/context/DataContext.jsx
new file mode 100644
index 0000000..82d049f
--- /dev/null
+++ b/src/context/DataContext.jsx
@@ -0,0 +1,23 @@
+import { createContext } from "react";
+import PropTypes from "prop-types";
+import { useData } from "@/hooks/useData";
+
+export const DataContext = createContext();
+
+export const DataProvider = ({ config, children }) => {
+ const data = useData(config);
+
+ return (
+
+ {children}
+
+ );
+};
+
+DataProvider.propTypes = {
+ config: PropTypes.shape({
+ baseUrl: PropTypes.string.isRequired,
+ params: PropTypes.object,
+ }).isRequired,
+ children: PropTypes.node.isRequired,
+};
\ No newline at end of file
diff --git a/src/css/AnimatedDropdown.css b/src/css/AnimatedDropdown.css
new file mode 100644
index 0000000..e1ddcf4
--- /dev/null
+++ b/src/css/AnimatedDropdown.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/css/App.css b/src/css/App.css
new file mode 100644
index 0000000..e69de29
diff --git a/src/css/CustomCarousel.css b/src/css/CustomCarousel.css
new file mode 100644
index 0000000..4e44fca
--- /dev/null
+++ b/src/css/CustomCarousel.css
@@ -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;
+}
diff --git a/src/css/FileUpload.css b/src/css/FileUpload.css
new file mode 100644
index 0000000..6e442d1
--- /dev/null
+++ b/src/css/FileUpload.css
@@ -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);
+}
\ No newline at end of file
diff --git a/src/css/PasswordInput.css b/src/css/PasswordInput.css
new file mode 100644
index 0000000..e69de29
diff --git a/src/css/index.css b/src/css/index.css
new file mode 100644
index 0000000..321b501
--- /dev/null
+++ b/src/css/index.css
@@ -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 {
+ 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;
+}
\ No newline at end of file
diff --git a/src/hooks/useAuth.js b/src/hooks/useAuth.js
new file mode 100644
index 0000000..a6e5c3a
--- /dev/null
+++ b/src/hooks/useAuth.js
@@ -0,0 +1,4 @@
+import { useContext } from "react";
+import { AuthContext } from "../context/AuthContext";
+
+export const useAuth = () => useContext(AuthContext);
diff --git a/src/hooks/useConfig.js b/src/hooks/useConfig.js
new file mode 100644
index 0000000..a895e6b
--- /dev/null
+++ b/src/hooks/useConfig.js
@@ -0,0 +1,4 @@
+import { useContext } from "react";
+import { ConfigContext } from "../context/ConfigContext.jsx";
+
+export const useConfig = () => useContext(ConfigContext);
diff --git a/src/hooks/useData.js b/src/hooks/useData.js
new file mode 100644
index 0000000..a1c5dd5
--- /dev/null
+++ b/src/hooks/useData.js
@@ -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,
+ };
+};
diff --git a/src/hooks/useDataContext.js b/src/hooks/useDataContext.js
new file mode 100644
index 0000000..f4b6a80
--- /dev/null
+++ b/src/hooks/useDataContext.js
@@ -0,0 +1,4 @@
+import { useContext } from "react";
+import { DataContext } from "../context/DataContext";
+
+export const useDataContext = () => useContext(DataContext);
diff --git a/src/hooks/usePaginatedList.js b/src/hooks/usePaginatedList.js
new file mode 100644
index 0000000..c5b200a
--- /dev/null
+++ b/src/hooks/usePaginatedList.js
@@ -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
+ };
+};
diff --git a/src/hooks/useSessionRenewal.jsx b/src/hooks/useSessionRenewal.jsx
new file mode 100644
index 0000000..0155976
--- /dev/null
+++ b/src/hooks/useSessionRenewal.jsx
@@ -0,0 +1,103 @@
+import { useEffect, useState } from "react";
+import { parseJwt } from "@/util/tokenUtils.js";
+import NotificationModal from "@/components/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 && (
+ {
+ setShowModal(false);
+ logout();
+ }}
+ title="¿Quieres seguir conectado?"
+ message="Tu sesión está a punto de expirar. ¿Quieres renovarla 1 hora más?"
+ variant="info"
+ buttons={[
+ {
+ label: "Renovar sesión",
+ variant: "success",
+ onClick: handleRenew,
+ },
+ {
+ label: "Cerrar sesión",
+ variant: "danger",
+ onClick: () => {
+ logout();
+ setShowModal(false);
+ },
+ },
+ ]}
+ />
+ );
+
+ return { modal };
+};
+
+export default useSessionRenewal;
diff --git a/src/icons.jsx b/src/icons.jsx
new file mode 100644
index 0000000..7758bf5
--- /dev/null
+++ b/src/icons.jsx
@@ -0,0 +1,185 @@
+const Icons = {
+ Menu:
+
+
+ ,
+
+ Close:
+
+
+ ,
+
+ Eye:
+
+
+ ,
+
+ EyeSlash:
+
+
+ ,
+
+ CheckboxOn:
+
+
+ ,
+
+ CheckboxOff:
+
+
+ ,
+
+ Home:
+
+
+ ,
+
+ AddGrid:
+
+
+ ,
+
+ Users:
+
+
+ ,
+
+ Login:
+
+
+ ,
+
+ Logout:
+
+
+ ,
+
+ Download:
+
+
+ ,
+
+ FilePlus:
+
+
+ ,
+
+ Edit:
+
+
+ ,
+
+ Trash:
+
+
+ ,
+
+ Dots:
+
+
+ ,
+
+ Save:
+
+
+ ,
+
+ Cancel:
+
+
+ ,
+}
+
+export default Icons;
\ No newline at end of file
diff --git a/src/main.jsx b/src/main.jsx
new file mode 100644
index 0000000..39e5ee4
--- /dev/null
+++ b/src/main.jsx
@@ -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(
+
+
+
+
+
+
+
+
+
+)
\ No newline at end of file
diff --git a/src/util/alertHelpers.jsx b/src/util/alertHelpers.jsx
new file mode 100644
index 0000000..17124b8
--- /dev/null
+++ b/src/util/alertHelpers.jsx
@@ -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 (
+
+ {typeof error === 'string' ? error : 'An unexpected error occurred.'}
+
+ );
+};
+
+export const resetErrorIfEditEnds = (editMode, setError) => {
+ if (!editMode) setError(null);
+};
diff --git a/src/util/constants.js b/src/util/constants.js
new file mode 100644
index 0000000..aeff65b
--- /dev/null
+++ b/src/util/constants.js
@@ -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 };
diff --git a/src/util/date.js b/src/util/date.js
new file mode 100644
index 0000000..c9d9dc3
--- /dev/null
+++ b/src/util/date.js
@@ -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 }
\ No newline at end of file
diff --git a/src/util/parsers/dateParser.js b/src/util/parsers/dateParser.js
new file mode 100644
index 0000000..dc80506
--- /dev/null
+++ b/src/util/parsers/dateParser.js
@@ -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);
+ }
+};
diff --git a/src/util/parsers/errorParser.js b/src/util/parsers/errorParser.js
new file mode 100644
index 0000000..971bcd8
--- /dev/null
+++ b/src/util/parsers/errorParser.js
@@ -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";
+ }
+};
diff --git a/src/util/passwordGenerator.js b/src/util/passwordGenerator.js
new file mode 100644
index 0000000..9a99610
--- /dev/null
+++ b/src/util/passwordGenerator.js
@@ -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('');
+};
diff --git a/src/util/tokenUtils.js b/src/util/tokenUtils.js
new file mode 100644
index 0000000..38e5970
--- /dev/null
+++ b/src/util/tokenUtils.js
@@ -0,0 +1,7 @@
+export const parseJwt = (token) => {
+ try {
+ return JSON.parse(atob(token.split('.')[1]));
+ } catch (e) {
+ return null;
+ }
+};
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..7985dce
--- /dev/null
+++ b/vite.config.js
@@ -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'],
+ }
+ }
+ }
+ }
+})
\ No newline at end of file