Initial commit
38
eslint.config.js
Normal 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
@@ -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>ETSIIMC</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
5183
package-lock.json
generated
Normal file
49
package.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "etsiimc",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
public/config/settings.dev.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
public/config/settings.prod.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/fonts/mc-text-bold-italic.otf
Normal file
BIN
public/fonts/mc-text-bold.otf
Normal file
BIN
public/fonts/mc-text-italic.otf
Normal file
BIN
public/fonts/mc-text-regular.otf
Normal file
BIN
public/fonts/mc-titles.ttf
Normal file
BIN
public/images/background.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
public/images/bg_dirt.webp
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/images/building.webp
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
public/images/diamond_pickaxe.cur
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/images/diamond_sword.cur
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/images/etsiimc.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
public/images/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
6
public/privacy.txt
Normal 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
@@ -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
@@ -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;
|
||||||
25
src/components/auth/CustomCheckbox.jsx
Normal 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;
|
||||||
8
src/components/auth/IfAuthenticated.jsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { useAuth } from "@/hooks/useAuth.js";
|
||||||
|
|
||||||
|
const IfAuthenticated = ({ children }) => {
|
||||||
|
const { authStatus } = useAuth();
|
||||||
|
return authStatus === "authenticated" ? children : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IfAuthenticated;
|
||||||
8
src/components/auth/IfNotAuthenticated.jsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { useAuth } from "@/hooks/useAuth.js";
|
||||||
|
|
||||||
|
const IfNotAuthenticated = ({ children }) => {
|
||||||
|
const { authStatus } = useAuth();
|
||||||
|
return authStatus === "unauthenticated" ? children : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IfNotAuthenticated;
|
||||||
13
src/components/auth/IfRole.jsx
Normal file
@@ -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;
|
||||||
102
src/components/auth/LoginForm.jsx
Normal 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;
|
||||||
43
src/components/auth/PasswordInput.jsx
Normal 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;
|
||||||
20
src/components/auth/ProfilePicture.jsx
Normal 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;
|
||||||
18
src/components/auth/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { useAuth } from "@/hooks/useAuth.js";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
const ProtectedRoute = ({ minimumRoles, children }) => {
|
||||||
|
const { authStatus } = useAuth();
|
||||||
|
|
||||||
|
if (authStatus === "checking") return <FontAwesomeIcon icon={faSpinner} />; // 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;
|
||||||
109
src/components/inputs/FileUpload.jsx
Normal 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;
|
||||||
43
src/components/inputs/SearchToolbar.jsx
Normal 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;
|
||||||
24
src/components/inputs/SpanishDateTimePicker.jsx
Normal 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;
|
||||||
24
src/components/layout/Building.jsx
Normal 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;
|
||||||
16
src/components/layout/ContentWrapper.jsx
Normal 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;
|
||||||
47
src/components/layout/CustomCarousel.jsx
Normal 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;
|
||||||
15
src/components/layout/CustomContainer.jsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
const CustomContainer = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<main className={`px-4 py-5`}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomContainer.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CustomContainer;
|
||||||
20
src/components/layout/Footer.jsx
Normal 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> | Mozilla Public License 2.0</p>
|
||||||
|
<div className="footer-links">
|
||||||
|
<a href="/etsiimc/privacy.txt" className="minecraft-btn">
|
||||||
|
Politica de Privacidad
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
|
||||||
|
Footer.propTypes = {
|
||||||
|
sticky: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
139
src/components/layout/Header.jsx
Normal 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/etsiimc.png`}
|
||||||
|
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;
|
||||||
24
src/components/layout/PaginatedCardGrid.jsx
Normal 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;
|
||||||
28
src/components/modals/CustomModal.jsx
Normal 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;
|
||||||
69
src/components/modals/NotificationModal.jsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Modal, Button } from 'react-bootstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faCircleCheck,
|
||||||
|
faCircleXmark,
|
||||||
|
faCircleExclamation,
|
||||||
|
faCircleInfo
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
success: faCircleCheck,
|
||||||
|
danger: faCircleXmark,
|
||||||
|
warning: faCircleExclamation,
|
||||||
|
info: faCircleInfo
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotificationModal = ({
|
||||||
|
show,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
variant = "info",
|
||||||
|
buttons = [{ label: "Aceptar", variant: "primary", onClick: onClose }]
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal show={show} onHide={onClose} centered>
|
||||||
|
<Modal.Header closeButton className={`bg-${variant} ${variant === 'info' ? 'text-dark' : 'text-white'}`}>
|
||||||
|
<Modal.Title>
|
||||||
|
<FontAwesomeIcon icon={iconMap[variant] || faCircleInfo} className="me-2" />
|
||||||
|
{title}
|
||||||
|
</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
|
||||||
|
<Modal.Body>
|
||||||
|
<p className="mb-0">{message}</p>
|
||||||
|
</Modal.Body>
|
||||||
|
|
||||||
|
<Modal.Footer>
|
||||||
|
{buttons.map((btn, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant={btn.variant || "primary"}
|
||||||
|
onClick={btn.onClick || onClose}
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
NotificationModal.propTypes = {
|
||||||
|
show: PropTypes.bool.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
message: PropTypes.string.isRequired,
|
||||||
|
variant: PropTypes.oneOf(['success', 'danger', 'warning', 'info']),
|
||||||
|
buttons: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
label: PropTypes.string.isRequired,
|
||||||
|
variant: PropTypes.string,
|
||||||
|
onClick: PropTypes.func
|
||||||
|
})
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationModal;
|
||||||
137
src/components/mods/Mod.jsx
Normal 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;
|
||||||
66
src/components/mods/ModListByDate.jsx
Normal 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;
|
||||||
98
src/components/pages/Inicio.jsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import CustomContainer from "@/components/layout/CustomContainer";
|
||||||
|
import { Col, Row, Modal } from "react-bootstrap";
|
||||||
|
import { useState } from "react";
|
||||||
|
import ContentWrapper from "../layout/ContentWrapper";
|
||||||
|
|
||||||
|
const Inicio = () => {
|
||||||
|
const [modalShown, setModalShown] = useState(false);
|
||||||
|
|
||||||
|
const copiarIP = () => {
|
||||||
|
navigator.clipboard.writeText('miarma.net');
|
||||||
|
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 así que tienes dos opciones:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li className="text-start">Descargar el launcher PrismLauncher (recomendado, launcher <a href="https://en.wikipedia.org/wiki/Open-source_software" target="_blank" rel="noopener noreferrer">OSS</a>).</li>
|
||||||
|
<li className="text-start">Comprarlo en la página oficial.</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
El servidor se encuentra en la version <b>1.21.10</b>, así que tendrás que seleccionar esa versión en el launcher. Permitimos <b>mods</b> client-side que no den ventaja sobre otros jugadores (como shaders, Mouse-Tweaks, etc).
|
||||||
|
</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("https://github.com/PrismLauncher/PrismLauncher/releases/latest", "_blank"); }} className="minecraft-btn danger">Descargar Prism Launcher</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step === 2 && (
|
||||||
|
<>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === 3 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { copiarIP(); setModalShown(true); }}
|
||||||
|
className="minecraft-btn"
|
||||||
|
>
|
||||||
|
COPIAR IP
|
||||||
|
</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;
|
||||||
9
src/components/pages/Jugadores.jsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Building from "@/components/layout/Building";
|
||||||
|
|
||||||
|
const Jugadores = () => {
|
||||||
|
return (
|
||||||
|
<Building />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Jugadores;
|
||||||
11
src/components/pages/Login.jsx
Normal 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;
|
||||||
166
src/components/pages/Mods.jsx
Normal 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/etsiimc/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;
|
||||||
75
src/components/pages/Profile.jsx
Normal 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;
|
||||||
105
src/components/util/AnimatedDropdown.jsx
Normal 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;
|
||||||
110
src/components/util/AnimatedDropend.jsx
Normal 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;
|
||||||
10
src/components/util/LoadingIcon.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
|
||||||
|
const LoadingIcon = () => {
|
||||||
|
return (
|
||||||
|
<FontAwesomeIcon icon={faSpinner} className='fa-spin fa-lg' />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LoadingIcon;
|
||||||
8
src/constants.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CONSTANTS = {
|
||||||
|
ADMIN_ROLE: 1,
|
||||||
|
PLAYER_ROLE: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export { CONSTANTS };
|
||||||
98
src/context/AuthContext.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
41
src/context/ConfigContext.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { createContext, useState, useEffect } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
const ConfigContext = createContext();
|
||||||
|
|
||||||
|
export const ConfigProvider = ({ children }) => {
|
||||||
|
const [config, setConfig] = useState(null);
|
||||||
|
const [configLoading, setLoading] = useState(true);
|
||||||
|
const [configError, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
const response = import.meta.env.MODE === 'production'
|
||||||
|
? await fetch(`${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};
|
||||||
23
src/context/DataContext.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { useData } from "@/hooks/useData";
|
||||||
|
|
||||||
|
export const DataContext = createContext();
|
||||||
|
|
||||||
|
export const DataProvider = ({ config, 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,
|
||||||
|
};
|
||||||
54
src/css/AnimatedDropdown.css
Normal 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
11
src/css/CustomCarousel.css
Normal 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
@@ -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);
|
||||||
|
}
|
||||||
0
src/css/PasswordInput.css
Normal file
519
src/css/index.css
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
/* ===========================
|
||||||
|
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: #6099c4;
|
||||||
|
--btn-primary-inner-hover-color: #4a7aa3;
|
||||||
|
--btn-primary-inner-shadow-color: #2a5e80;
|
||||||
|
--btn-primary-border-color: #1e1e1f;
|
||||||
|
--btn-primary-inner-border-lt-color: #6099c4bf;
|
||||||
|
--btn-primary-inner-border-br-color: #6099c4;
|
||||||
|
|
||||||
|
--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
|
||||||
|
=========================== */
|
||||||
|
* {
|
||||||
|
cursor: url('/images/diamond_pickaxe.cur'), auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
a, button, a.nav-link>div, .minecraft-btn>*, .navbar-brand>img {
|
||||||
|
cursor: url('/images/diamond_sword.cur'), pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
@@ -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
@@ -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
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
4
src/hooks/useDataContext.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { DataContext } from "../context/DataContext";
|
||||||
|
|
||||||
|
export const useDataContext = () => useContext(DataContext);
|
||||||
48
src/hooks/usePaginatedList.js
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
103
src/hooks/useSessionRenewal.jsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { parseJwt } from "@/util/tokenUtils.js";
|
||||||
|
import NotificationModal from "@/components/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
@@ -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
@@ -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='/etsiimc/'>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</AuthProvider>
|
||||||
|
</ConfigProvider>
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
15
src/util/alertHelpers.jsx
Normal 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
@@ -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
@@ -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 }
|
||||||
30
src/util/parsers/dateParser.js
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
10
src/util/parsers/errorParser.js
Normal 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";
|
||||||
|
}
|
||||||
|
};
|
||||||
29
src/util/passwordGenerator.js
Normal 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
@@ -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
@@ -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: '/etsiimc/',
|
||||||
|
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'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||