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'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||