[REPO REFACTOR]: changed to a better git repository structure with branches
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>MiarmaCraft</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"]
|
||||
}
|
||||
49
package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "miarmacraftreact",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"axios": "^1.9.0",
|
||||
"bootstrap": "^5.3.5",
|
||||
"date-fns": "^2.30.0",
|
||||
"dompurify": "^3.2.5",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^12.6.1",
|
||||
"pixelarticons": "^1.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-bootstrap": "^2.10.9",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.1.5",
|
||||
"react-simple-wysiwyg": "^3.2.2",
|
||||
"react-skinview3d": "^5.1.0",
|
||||
"react-slick": "^0.30.3",
|
||||
"react-split": "^2.0.14",
|
||||
"slick-carousel": "^1.8.1",
|
||||
"vite-plugin-clean": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
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/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
429
public/images/miarmacraft.svg
Normal file
|
After Width: | Height: | Size: 301 KiB |
31
public/images/miarmacraft_mods.svg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
public/images/sign.jpg
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
public/images/title.gif
Normal file
|
After Width: | Height: | Size: 470 KiB |
BIN
public/images/title.png
Normal file
|
After Width: | Height: | Size: 32 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> | Todos los derechos reservados.</p>
|
||||
<div className="footer-links">
|
||||
<a href="/miarmacraft/privacy.txt" className="minecraft-btn">
|
||||
Politica de Privacidad
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
Footer.propTypes = {
|
||||
sticky: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
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/miarmacraft.svg`}
|
||||
className="img-fluid"
|
||||
style={{ maxHeight: '60px' }}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
className="menu-toggle d-lg-none"
|
||||
onClick={toggleMenu}
|
||||
aria-controls="header-collapse"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? Icons.Close : Icons.Menu}
|
||||
</button>
|
||||
|
||||
<nav className="header-nav d-none d-lg-flex gap-4 align-items-center">
|
||||
<Link to="" className='nav-link'>
|
||||
<div className='align-items-center d-flex gap-2'>
|
||||
{Icons.Home}
|
||||
INICIO
|
||||
</div>
|
||||
</Link>
|
||||
<IfRole roles={[CONSTANTS.ADMIN_ROLE]}>
|
||||
<Link to="mods" className='nav-link'>
|
||||
<div className='align-items-center d-flex gap-2'>
|
||||
{Icons.AddGrid}
|
||||
MODS
|
||||
</div>
|
||||
</Link>
|
||||
</IfRole>
|
||||
<IfRole roles={[CONSTANTS.ADMIN_ROLE]}>
|
||||
<Link to="jugadores" className='nav-link'>
|
||||
<div className='align-items-center d-flex gap-2'>
|
||||
{Icons.Users}
|
||||
JUGADORES
|
||||
</div>
|
||||
</Link>
|
||||
</IfRole>
|
||||
<IfAuthenticated>
|
||||
<ProfilePicture userName={user?.user_name} part="helm" />
|
||||
</IfAuthenticated>
|
||||
<IfNotAuthenticated>
|
||||
<ProfilePicture userName="Steve" part="avatar" />
|
||||
</IfNotAuthenticated>
|
||||
<IfNotAuthenticated>
|
||||
<Link to="login">
|
||||
<button className="minecraft-btn">
|
||||
<div className='align-items-center d-flex gap-2'>
|
||||
{Icons.Login}
|
||||
INICIAR SESION
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
</IfNotAuthenticated>
|
||||
<IfAuthenticated>
|
||||
<button className="minecraft-btn danger" onClick={logout}>
|
||||
<div className='align-items-center d-flex gap-2'>
|
||||
{Icons.Logout}
|
||||
CERRAR SESION
|
||||
</div>
|
||||
</button>
|
||||
</IfAuthenticated>
|
||||
</nav>
|
||||
</Container>
|
||||
|
||||
<Collapse in={open}>
|
||||
<div id="header-collapse" className="header-nav-mobile d-lg-none">
|
||||
<Link to="" className='nav-link mt-4' onClick={closeMenu}>
|
||||
<div className='align-items-center d-flex gap-2'>
|
||||
{Icons.Home}
|
||||
INICIO
|
||||
</div>
|
||||
</Link>
|
||||
<IfRole roles={[CONSTANTS.ADMIN_ROLE, CONSTANTS.PLAYER_ROLE]}>
|
||||
<Link to="mods" className='nav-link mt-4' onClick={closeMenu}>
|
||||
<div className='align-items-center d-flex gap-2'>
|
||||
{Icons.AddGrid}
|
||||
MODS
|
||||
</div>
|
||||
</Link>
|
||||
</IfRole>
|
||||
<IfRole roles={[CONSTANTS.ADMIN_ROLE]}>
|
||||
<Link to="jugadores" className='nav-link mt-4' onClick={closeMenu}>
|
||||
<div className='align-items-center d-flex gap-2'>
|
||||
{Icons.Users}
|
||||
JUGADORES
|
||||
</div>
|
||||
</Link>
|
||||
</IfRole>
|
||||
<IfNotAuthenticated>
|
||||
<Link to="login">
|
||||
<button className="minecraft-btn mt-4" onClick={closeMenu}>
|
||||
<div className='align-items-center d-flex gap-2'>
|
||||
{Icons.Login}
|
||||
INICIAR SESION
|
||||
</div>
|
||||
</button>
|
||||
</Link>
|
||||
</IfNotAuthenticated>
|
||||
<IfAuthenticated>
|
||||
<button className="minecraft-btn danger mt-4" onClick={() => { logout(); closeMenu(); }}>
|
||||
<div className='align-items-center d-flex gap-2'>
|
||||
{Icons.Logout}
|
||||
CERRAR SESION
|
||||
</div>
|
||||
</button>
|
||||
</IfAuthenticated>
|
||||
</div>
|
||||
</Collapse>
|
||||
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
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;
|
||||
113
src/components/pages/Inicio.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import CustomContainer from "@/components/layout/CustomContainer";
|
||||
import { Col, Row, Modal } from "react-bootstrap";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import ContentWrapper from "../layout/ContentWrapper";
|
||||
|
||||
const Inicio = () => {
|
||||
const [modalShown, setModalShown] = useState(false);
|
||||
|
||||
const copiarIP = (mode) => {
|
||||
navigator.clipboard.writeText(mode === 'V' ? 'miarma.net' : 'miarma.net:25566');
|
||||
setModalShown(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomContainer>
|
||||
<ContentWrapper>
|
||||
<h1 className="text-center mt3 mb-5">Pasos para unirse al servidor</h1>
|
||||
|
||||
<Row className="g-4 align-items-stretch mb-5">
|
||||
{[1, 2, 3].map((step) => (
|
||||
<Col key={step} sm={12} md={4} className="d-flex">
|
||||
<div className="minecraft-card flex-fill d-flex flex-column">
|
||||
|
||||
{/* —— Contenido “arriba” ————————————————————— */}
|
||||
<h1 className="header text-center">
|
||||
Paso {step}
|
||||
</h1>
|
||||
|
||||
<div className="card-body">
|
||||
{step === 1 && (
|
||||
<>
|
||||
<p>Necesitas tener el juego para entrar en el servidor (gracias capitán obvio) así que tienes dos opciones:
|
||||
</p>
|
||||
<ul>
|
||||
<li className="text-start">Comprarlo en la página oficial.</li>
|
||||
<li className="text-start">Descargar el launcher SKLauncher (no recomendado).</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<p>
|
||||
Para jugar al server Vanilla++ deberás entrar en la versión 1.21.8 vanilla sin más.
|
||||
</p>
|
||||
<p>
|
||||
Sin embargo, si deseas jugar al servidor con mods necesitas descargar el modpack (paquete de mods) que tenemos en el servidor. En caso de que necesitases algún mod específico, puedes mirarlo en la <Link to={"/mods"}>lista de mods</Link>.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<p>Por último solamente te queda copiar la dirección del servidor e introducirla en el juego para conectarte y jugar :D
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* —— Footer con el hr + botones ————————————————— */}
|
||||
<div className="card-footer mt-auto d-flex flex-column align-items-center gap-2">
|
||||
<hr className="minecraft-hr w-100" />
|
||||
{step === 1 && (
|
||||
<div className="d-flex flex-column gap-2">
|
||||
<button onClick={() => { window.open("https://minecraft.net/", "_blank"); }} className="minecraft-btn">Comprar Minecraft</button>
|
||||
<button onClick={() => { window.open("/files/miarmacraft/SKLauncher.exe", "_blank"); }} className="minecraft-btn danger">Descargar SKLauncher</button>
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<>
|
||||
<Link to="https://miarma.net/files/miarmacraft/MiarmaPack.zip" className="minecraft-btn">
|
||||
Descargar Modpack
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { copiarIP('V'); setModalShown(true); }}
|
||||
className="minecraft-btn"
|
||||
>
|
||||
IP Vanilla++
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { copiarIP('F'); setModalShown(true); }}
|
||||
className="minecraft-btn"
|
||||
>
|
||||
IP Forge
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
|
||||
<Modal show={modalShown} onHide={() => setModalShown(false)}>
|
||||
<Modal.Body className="text-center">
|
||||
<h1>IP COPIADA</h1>
|
||||
<p>Nos vemos dentro del server.</p>
|
||||
<button onClick={() => setModalShown(false)} className="minecraft-btn">
|
||||
Cerrar
|
||||
</button>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</ContentWrapper>
|
||||
</CustomContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Inicio;
|
||||
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/miarmacraft/MiarmaPack.zip", "_blank"); }}
|
||||
>
|
||||
Descargar modpack
|
||||
</button>
|
||||
</div>
|
||||
<ModListByDate
|
||||
mods={data}
|
||||
onUpdate={handleEditSubmit}
|
||||
onDelete={handleDelete}
|
||||
onClearError={() => setError(null)}
|
||||
/>
|
||||
|
||||
<CustomModal
|
||||
show={showModModal}
|
||||
onClose={handleCancelCreate}
|
||||
>
|
||||
<div className="p-4">
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger mb-3" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="list-unstyled m-0 p-0">
|
||||
<Mod
|
||||
mod={tempMod}
|
||||
isNew
|
||||
fileRef={fileRef}
|
||||
onCreate={handleCreateSubmit}
|
||||
onCancel={handleCancelCreate}
|
||||
onClearError={() => setError(null)}
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</CustomModal>
|
||||
|
||||
<CustomModal
|
||||
show={deleteTargetId !== null}
|
||||
onClose={() => setDeleteTargetId(null)}
|
||||
>
|
||||
<p className='p-3'>¿Estás seguro de que quieres eliminar este mod?</p>
|
||||
<div className="d-flex justify-content-end gap-2 mt-3 p-3">
|
||||
<button className='minecraft-btn' onClick={() => setDeleteTargetId(null)}>Cancelar</button>
|
||||
<button
|
||||
className='minecraft-btn danger'
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteData(`${reqConfig.baseUrl}/${deleteTargetId}`);
|
||||
setDeleteTargetId(null);
|
||||
} catch (err) {
|
||||
setError(errorParser(err));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</CustomModal>
|
||||
</ContentWrapper>
|
||||
</CustomContainer>
|
||||
);
|
||||
};
|
||||
|
||||
ModsContent.propTypes = {
|
||||
reqConfig: PropTypes.shape({
|
||||
baseUrl: PropTypes.string.isRequired,
|
||||
params: PropTypes.object.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default Mods;
|
||||
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
511
src/css/index.css
Normal file
@@ -0,0 +1,511 @@
|
||||
/* ===========================
|
||||
VARIABLES GLOBALES
|
||||
=========================== */
|
||||
:root {
|
||||
--primary-color: #313233;
|
||||
--secondary-color: #48494a;
|
||||
--accent-color: #d0d1d4;
|
||||
--background-color: #48494a;
|
||||
--text-color: #ffffff;
|
||||
--text-dark-color: #000000;
|
||||
|
||||
--btn-primary-inner-color: #3c8527;
|
||||
--btn-primary-inner-hover-color: #2a641c;
|
||||
--btn-primary-inner-shadow-color: #1d4d13;
|
||||
--btn-primary-border-color: #1e1e1f;
|
||||
--btn-primary-inner-border-lt-color: #4f913cbf;
|
||||
--btn-primary-inner-border-br-color: #639d52;
|
||||
|
||||
--btn-danger-inner-color: #c72a2a;
|
||||
--btn-danger-inner-hover-color: #a61e1e;
|
||||
--btn-danger-inner-shadow-color: #7a1c1c;
|
||||
--btn-danger-border-color: #1e1e1f;
|
||||
--btn-danger-inner-border-lt-color: #c72a2abf;
|
||||
--btn-danger-inner-border-br-color: #c72a2a;
|
||||
|
||||
--btn-tertiary-inner-shadow-color: #58585a;
|
||||
--hr-top-color: #333334;
|
||||
--hr-bottom-color: #5a5b5c;
|
||||
|
||||
--added-color: #4f913c;
|
||||
--removed-color: #c72a2a;
|
||||
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 16px;
|
||||
--spacing-lg: 32px;
|
||||
--spacing-xl: 64px;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
FUENTES
|
||||
=========================== */
|
||||
@font-face {
|
||||
font-family: 'MCTitles';
|
||||
src: url('/fonts/mc-titles.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'MCText Regular';
|
||||
src: url('/fonts/mc-text-regular.otf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'MCText Bold';
|
||||
src: url('/fonts/mc-text-bold.otf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'MCText Italic';
|
||||
src: url('/fonts/mc-text-italic.otf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'MCText Bold Italic';
|
||||
src: url('/fonts/mc-text-bold-italic.otf');
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
RESET BÁSICO
|
||||
=========================== */
|
||||
body {
|
||||
font-family: 'MCText Regular';
|
||||
background-image: url("/images/bg_dirt.webp");
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: 'MCTitles';
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: 'MCText Regular';
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
HR (Separador)
|
||||
=========================== */
|
||||
.minecraft-hr {
|
||||
border: none;
|
||||
border-top: 2px solid var(--hr-top-color);
|
||||
border-bottom: 2px solid var(--hr-bottom-color);
|
||||
margin: var(--spacing-md) 0;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
CARD
|
||||
=========================== */
|
||||
|
||||
.minecraft-card {
|
||||
background-color: var(--background-color);
|
||||
padding: var(--spacing-md);
|
||||
color: var(--text-color);
|
||||
font-family: 'MCText Regular';
|
||||
font-size: 1.4rem;
|
||||
/* Biseles brutotes */
|
||||
box-shadow:
|
||||
inset 6px 6px 0 var(--btn-tertiary-inner-shadow-color),
|
||||
/* Bisel claro arriba izq */
|
||||
inset -6px -6px 0 #1d1d1d,
|
||||
/* Bisel oscuro abajo dcha */
|
||||
6px 6px 10px rgba(0, 0, 0, 0.5);
|
||||
/* Sombra exterior gorda */
|
||||
|
||||
border-radius: 0;
|
||||
/* Estilo cuadrado como un bloque */
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.minecraft-card header {
|
||||
font-family: 'MCTitles';
|
||||
font-size: 1.6rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--spacing-md);
|
||||
text-align: center;
|
||||
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.minecraft-card:hover:not(.not-animated) {
|
||||
/* Efecto "levantarse" al pasar el ratón */
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
inset 6px 6px 0 var(--btn-tertiary-inner-shadow-color),
|
||||
inset -6px -6px 0 #1d1d1d,
|
||||
10px 10px 20px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
BUILDING
|
||||
=========================== */
|
||||
|
||||
/* Construcción: imagen de mina y pico */
|
||||
.construction-img {
|
||||
box-shadow:
|
||||
inset 4px 4px 0 var(--btn-tertiary-inner-shadow-color),
|
||||
inset -4px -4px 0 #1d1d1d !important;
|
||||
}
|
||||
|
||||
/* Ajustes en la card para que no quede demasiado apretada */
|
||||
.minecraft-card.py-5 {
|
||||
padding-top: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
|
||||
/* ===========================
|
||||
INPUT
|
||||
=========================== */
|
||||
.minecraft-input {
|
||||
background-color: var(--primary-color);
|
||||
border: 3px solid var(--btn-primary-border-color);
|
||||
color: var(--text-color);
|
||||
font-family: 'MCText Regular';
|
||||
font-size: 1.4rem;
|
||||
height: 40px;
|
||||
padding: var(--spacing-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.minecraft-input:focus {
|
||||
outline: none;
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.minecraft-select {
|
||||
background-color: var(--primary-color);
|
||||
border: 3px solid var(--btn-primary-border-color);
|
||||
color: var(--text-color);
|
||||
font-family: 'MCText Regular';
|
||||
font-size: 1.4rem;
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
background-image: url("/icons/down_arrow.svg");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
background-size: 1rem;
|
||||
}
|
||||
|
||||
.minecraft-select:focus {
|
||||
outline: none;
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.minecraft-select>option {
|
||||
appearance: none;
|
||||
color: var(--text-color);
|
||||
font-family: 'MCText Regular';
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
|
||||
/* ===========================
|
||||
CHECKBOX
|
||||
=========================== */
|
||||
|
||||
.minecraft-checkbox-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
gap: var(--spacing-sm);
|
||||
user-select: none;
|
||||
font-family: 'MCText Regular';
|
||||
}
|
||||
|
||||
.minecraft-checkbox {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: var(--primary-color);
|
||||
border: 3px solid var(--btn-primary-border-color);
|
||||
box-shadow:
|
||||
inset 4px 4px var(--btn-tertiary-inner-shadow-color),
|
||||
inset -4px -4px #1d1d1d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.1s;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
color: var(--accent-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pixel-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pixel-icon.big {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.minecraft-checkbox.checked {
|
||||
background-color: var(--btn-primary-inner-color);
|
||||
box-shadow:
|
||||
inset 4px 4px var(--btn-primary-inner-border-lt-color),
|
||||
inset -4px -4px var(--btn-primary-inner-border-br-color);
|
||||
border-color: var(--btn-primary-border-color);
|
||||
}
|
||||
|
||||
.minecraft-checkbox-label {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===========================
|
||||
BOTÓN (General)
|
||||
=========================== */
|
||||
.minecraft-btn {
|
||||
background-color: var(--btn-primary-inner-color);
|
||||
border: 3px solid var(--btn-primary-border-color);
|
||||
box-shadow:
|
||||
inset 0 -6px var(--btn-primary-inner-shadow-color),
|
||||
inset 3px 3px var(--btn-primary-inner-border-lt-color),
|
||||
inset -3px -9px var(--btn-primary-inner-border-br-color);
|
||||
color: var(--text-color);
|
||||
font-family: 'MCTitles';
|
||||
font-size: 1.2em;
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.minecraft-btn.danger {
|
||||
background-color: var(--btn-danger-inner-color);
|
||||
border: 3px solid var(--btn-danger-border-color);
|
||||
box-shadow:
|
||||
inset 0 -6px var(--btn-danger-inner-shadow-color),
|
||||
inset 3px 3px var(--btn-danger-inner-border-lt-color),
|
||||
inset -3px -9px var(--btn-danger-inner-border-br-color);
|
||||
color: var(--text-color);
|
||||
font-family: 'MCTitles';
|
||||
font-size: 1.2em;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
button[disabled].minecraft-btn {
|
||||
background-color: var(--btn-primary-inner-color);
|
||||
border: 3px solid var(--btn-primary-border-color);
|
||||
box-shadow:
|
||||
inset 0 -6px var(--btn-primary-inner-shadow-color),
|
||||
inset 3px 3px var(--btn-primary-inner-border-lt-color),
|
||||
inset -3px -9px var(--btn-primary-inner-border-br-color);
|
||||
color: var(--bs-dark);
|
||||
font-family: 'MCTitles';
|
||||
font-size: 1.2em;
|
||||
padding: 8px 16px;
|
||||
cursor: not-allowed;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.minecraft-btn:hover {
|
||||
background-color: var(--btn-primary-inner-hover-color);
|
||||
}
|
||||
|
||||
.minecraft-btn.danger:hover {
|
||||
background-color: var(--btn-danger-inner-hover-color);
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
MODAL ESTILO MINECRAFT
|
||||
=========================== */
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--background-color) !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow:
|
||||
inset 6px 6px 0 var(--btn-tertiary-inner-shadow-color),
|
||||
inset -6px -6px 0 #1d1d1d,
|
||||
6px 6px 10px rgba(0, 0, 0, 0.5) !important;
|
||||
font-family: 'MCText Regular';
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.modal-content .modal-header {
|
||||
padding: var(--spacing-md) var(--spacing-lg) !important;
|
||||
background-color: var(--primary-color) !important;
|
||||
border-bottom: 2px solid var(--hr-top-color) !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow:
|
||||
inset 0 -2px 0 var(--hr-bottom-color) !important;
|
||||
}
|
||||
|
||||
/* Título */
|
||||
.modal-title {
|
||||
font-family: 'MCTitles' !important;
|
||||
font-size: 1.6rem !important;
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
/* Botón de cerrar */
|
||||
.btn-close {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
font-family: 'MCTitles' !important;
|
||||
font-size: 1.2rem !important;
|
||||
line-height: 1 !important;
|
||||
opacity: 1 !important;
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
color: var(--btn-primary-inner-hover-color) !important;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.modal-body {
|
||||
padding: var(--spacing-md) var(--spacing-lg) !important;
|
||||
font-family: 'MCText Regular' !important;
|
||||
font-size: 1.4rem !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
/* Si usas <Modal.Footer> */
|
||||
.modal-footer {
|
||||
padding: var(--spacing-md) var(--spacing-lg) !important;
|
||||
border-top: 2px solid var(--hr-top-color) !important;
|
||||
box-shadow:
|
||||
inset 0 2px 0 var(--hr-bottom-color) !important;
|
||||
justify-content: center;
|
||||
background-color: var(--primary-color) !important;
|
||||
}
|
||||
|
||||
/* Sobrescribir botones de footer (si los hubiera) */
|
||||
.modal-footer .btn {
|
||||
/* asume que ya tienes .minecraft-btn */
|
||||
all: unset;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
FOOTER ESTILO MINECRAFT
|
||||
=========================== */
|
||||
.minecraft-footer {
|
||||
background-color: var(--primary-color);
|
||||
padding: var(--spacing-lg) var(--spacing-md);
|
||||
color: var(--text-color);
|
||||
font-family: 'MCText Regular';
|
||||
text-align: center;
|
||||
|
||||
/* Igual que el nav pero en la parte superior */
|
||||
border-top: 4px solid var(--secondary-color);
|
||||
box-shadow: inset 0 6px var(--btn-tertiary-inner-shadow-color);
|
||||
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
|
||||
.minecraft-footer .footer-content p {
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.minecraft-footer .footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ===========================
|
||||
HEADER VISUAL
|
||||
=========================== */
|
||||
.header {
|
||||
background-color: var(--primary-color);
|
||||
border-bottom: 4px solid var(--secondary-color);
|
||||
box-shadow: inset 0 -6px var(--btn-tertiary-inner-shadow-color);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
font-size: 3.0rem;
|
||||
font-family: 'MCTitles';
|
||||
color: var(--accent-color);
|
||||
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.5);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Botón de hamburguesa */
|
||||
.menu-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Navegación Desktop */
|
||||
.header-nav a.nav-link {
|
||||
font-family: 'MCText Regular';
|
||||
font-size: 1.4rem;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-nav a.nav-link:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.header-nav a.nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background-color: var(--accent-color);
|
||||
bottom: -4px;
|
||||
left: 0;
|
||||
transform: scaleX(0);
|
||||
transform-origin: bottom right;
|
||||
transition: transform 0.25s ease-out;
|
||||
}
|
||||
|
||||
.header-nav a.nav-link:hover::after {
|
||||
transform: scaleX(1);
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
/* Menú Mobile */
|
||||
.header-nav-mobile {
|
||||
background-color: var(--primary-color);
|
||||
border-top: 3px solid var(--secondary-color);
|
||||
padding: var(--spacing-md) 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header-nav-mobile a.nav-link {
|
||||
font-family: 'MCText Regular';
|
||||
font-size: 1.4rem;
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Botones dentro del menú móvil */
|
||||
.header-nav-mobile .minecraft-form-btn {
|
||||
width: 80%;
|
||||
margin: var(--spacing-sm) auto;
|
||||
}
|
||||
4
src/hooks/useAuth.js
Normal file
@@ -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='/miarmacraft/'>
|
||||
<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: '/miarmacraft/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@/': '/src/',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1000, // para no ver el warning
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
react: ['react', 'react-dom'],
|
||||
router: ['react-router-dom'],
|
||||
motion: ['framer-motion'],
|
||||
axios: ['axios'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||