[REPO REFACTOR]: changed to a better git repository structure with branches
This commit is contained in:
33
eslint.config.js
Normal file
33
eslint.config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
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',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Huertos de Cine</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
jsconfig.json
Normal file
9
jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
46
package.json
Normal file
46
package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "cineapolis-garden",
|
||||
"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.16.0",
|
||||
"react": "^19.1.0",
|
||||
"react-bootstrap": "^2.10.10",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.1.5",
|
||||
"react-simple-wysiwyg": "^3.2.2",
|
||||
"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.25.0",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
47
public/config/settings.dev.json
Normal file
47
public/config/settings.dev.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"apiConfig": {
|
||||
"baseUrl": "https://api.miarma.net/cine/v1",
|
||||
"baseRawUrl": "https://api.miarma.net/cine/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"
|
||||
},
|
||||
"movies": {
|
||||
"getAll": "/movies",
|
||||
"getById": "/movies/:movie_id",
|
||||
"getVotes": "/movies/:movie_id/votes",
|
||||
"getVotesSelf": "/movies/:movie_id/votes/self"
|
||||
},
|
||||
"viewers": {
|
||||
"getAll": "/viewers",
|
||||
"getById": "/viewers/:viewer_id",
|
||||
"getVotesByUserAndMovieId": "/viewers/:viewer_id/votes/:movie_id",
|
||||
"metadata": "/viewers/metadata"
|
||||
},
|
||||
"files": {
|
||||
"all": "/files",
|
||||
"byId": "/files/:file_id",
|
||||
"upload": "/files/upload",
|
||||
"download": "/files/download/:file_id",
|
||||
"userFiles": "/files/myfiles"
|
||||
},
|
||||
"users": {
|
||||
"getAll": "/users",
|
||||
"getById": "/users/:user_id",
|
||||
"getStatus": "/users/:user_id/status",
|
||||
"getRole": "/users/:user_id/role",
|
||||
"checkExists": "/users/:user_id/exists",
|
||||
"getAvatar": "/users/:user_id/avatar",
|
||||
"updateAvatar": "/users/:user_id/avatar",
|
||||
"getSelfInfo": "/users/me"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
public/config/settings.prod.json
Normal file
47
public/config/settings.prod.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"apiConfig": {
|
||||
"baseUrl": "https://api.miarma.net/cine/v1",
|
||||
"baseRawUrl": "https://api.miarma.net/cine/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"
|
||||
},
|
||||
"movies": {
|
||||
"getAll": "/movies",
|
||||
"getById": "/movies/:movie_id",
|
||||
"getVotes": "/movies/:movie_id/votes",
|
||||
"getVotesSelf": "/movies/:movie_id/votes/self"
|
||||
},
|
||||
"viewers": {
|
||||
"getAll": "/viewers",
|
||||
"getById": "/viewers/:viewer_id",
|
||||
"getVotesByUserAndMovieId": "/viewers/:viewer_id/votes/:movie_id",
|
||||
"metadata": "/viewers/metadata"
|
||||
},
|
||||
"files": {
|
||||
"all": "/files",
|
||||
"byId": "/files/:file_id",
|
||||
"upload": "/files/upload",
|
||||
"download": "/files/download/:file_id",
|
||||
"userFiles": "/files/myfiles"
|
||||
},
|
||||
"users": {
|
||||
"getAll": "/users",
|
||||
"getById": "/users/:user_id",
|
||||
"getStatus": "/users/:user_id/status",
|
||||
"getRole": "/users/:user_id/role",
|
||||
"checkExists": "/users/:user_id/exists",
|
||||
"getAvatar": "/users/:user_id/avatar",
|
||||
"updateAvatar": "/users/:user_id/avatar",
|
||||
"getSelfInfo": "/users/me"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/fonts/OpenSans.ttf
Normal file
BIN
public/fonts/OpenSans.ttf
Normal file
Binary file not shown.
BIN
public/fonts/ProductSansBold.ttf
Normal file
BIN
public/fonts/ProductSansBold.ttf
Normal file
Binary file not shown.
BIN
public/fonts/ProductSansBoldItalic.ttf
Normal file
BIN
public/fonts/ProductSansBoldItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/ProductSansItalic.ttf
Normal file
BIN
public/fonts/ProductSansItalic.ttf
Normal file
Binary file not shown.
BIN
public/fonts/ProductSansRegular.ttf
Normal file
BIN
public/fonts/ProductSansRegular.ttf
Normal file
Binary file not shown.
40
src/App.jsx
Normal file
40
src/App.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import Header from "@/components/Header";
|
||||
import { Route, Routes, Navigate, Link } from 'react-router-dom'
|
||||
import Login from "@/pages/Login";
|
||||
import Votar from "@/pages/Votar";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import Footer from "@/components/Footer";
|
||||
import ProtectedRoute from "@/components/Auth/ProtectedRoute";
|
||||
import { CONSTANTS } from "@/util/constants";
|
||||
import FloatingMenu from "@/components/FloatingMenu/FloatingMenu";
|
||||
import IfRole from "@/components/Auth/IfRole";
|
||||
import Usuarios from "@/pages/Usuarios";
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/votar" replace />} />
|
||||
<Route path="/votar" element={
|
||||
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_USER, CONSTANTS.ROLE_ADMIN]}>
|
||||
<Votar />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/usuarios" element={
|
||||
<ProtectedRoute minimumRoles={[CONSTANTS.ROLE_ADMIN]}>
|
||||
<Usuarios />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<Footer />
|
||||
<IfRole roles={[CONSTANTS.ROLE_ADMIN]}>
|
||||
<FloatingMenu />
|
||||
</IfRole>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
14
src/api/axiosInstance.js
Normal file
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;
|
||||
92
src/components/AnimatedDropdown.jsx
Normal file
92
src/components/AnimatedDropdown.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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 AnimatedDropdown = ({
|
||||
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 = () => {
|
||||
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 shadow rounded-4 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnimatedDropdown;
|
||||
122
src/components/AnimatedDropend.jsx
Normal file
122
src/components/AnimatedDropend.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
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: e => {
|
||||
e.stopPropagation();
|
||||
toggle();
|
||||
},
|
||||
ref: triggerRef
|
||||
})
|
||||
: cloneElement(trigger, {
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
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;
|
||||
8
src/components/Auth/IfAuthenticated.jsx
Normal file
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
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
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;
|
||||
112
src/components/Auth/LoginForm.jsx
Normal file
112
src/components/Auth/LoginForm.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUser } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Form, Button, Alert } from 'react-bootstrap';
|
||||
import PasswordInput from './PasswordInput.jsx';
|
||||
|
||||
import { useContext, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AuthContext } from "@/context/AuthContext.jsx";
|
||||
import useBreakpoint from '@/hooks/useBreakpoint';
|
||||
|
||||
import '@/css/LoginForm.css';
|
||||
|
||||
const LoginForm = () => {
|
||||
const { login, error } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
const bp = useBreakpoint();
|
||||
|
||||
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 (
|
||||
<div className={`login-card card shadow p-5 ${['xs', 'sm'].includes(bp) ? "rounded-0" : "rounded-5"} mx-auto col-12 col-md-8 col-lg-6 col-xl-5 d-flex flex-column gap-4`}>
|
||||
<h1 className="text-center">Hola ¿te conozco?</h1>
|
||||
<Form className="d-flex flex-column gap-4" onSubmit={handleSubmit}>
|
||||
<div className="d-flex flex-column gap-3">
|
||||
<div className="position-relative w-100">
|
||||
<Form.Label htmlFor="login-input" className="fw-semibold">
|
||||
<FontAwesomeIcon icon={faUser} className="me-2" />
|
||||
Usuario o Email
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
id="login-input"
|
||||
type="text"
|
||||
name="emailOrUserName"
|
||||
value={formState.emailOrUserName}
|
||||
onChange={handleChange}
|
||||
className="rounded-4"
|
||||
placeholder="Escribe tu usuario o email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
value={formState.password}
|
||||
onChange={handleChange}
|
||||
name="password"
|
||||
/>
|
||||
|
||||
<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center gap-2">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
name="keepLoggedIn"
|
||||
label="Mantener sesión iniciada"
|
||||
className="text-secondary"
|
||||
value={formState.keepLoggedIn}
|
||||
onChange={(e) => {
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
keepLoggedIn: e.target.checked,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="danger" className="text-center py-2 mb-0">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<Button type="submit" className="w-75 padding-4 rounded-4 border-0 shadow-sm login-button">
|
||||
Iniciar sesión
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
48
src/components/Auth/PasswordInput.jsx
Normal file
48
src/components/Auth/PasswordInput.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react';
|
||||
import { Form } from 'react-bootstrap';
|
||||
import '../../css/PasswordInput.css';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEye, faEyeSlash, faKey } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Button } from 'react-bootstrap';
|
||||
|
||||
const PasswordInput = ({ value, onChange, name = "password" }) => {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
const toggleShow = () => setShow(prev => !prev);
|
||||
|
||||
return (
|
||||
<div className="position-relative w-100">
|
||||
<Form.Label htmlFor="passwordInput" className="fw-semibold">
|
||||
<FontAwesomeIcon icon={faKey} className="me-2" />
|
||||
Contraseña
|
||||
</Form.Label>
|
||||
|
||||
<div className="position-relative">
|
||||
<Form.Control
|
||||
id="passwordInput"
|
||||
type={show ? "text" : "password"}
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder="Escribe tu contraseña"
|
||||
onChange={onChange}
|
||||
className="rounded-4 pe-5"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="show-button position-absolute end-0 top-0 h-100 me-2"
|
||||
onClick={toggleShow}
|
||||
aria-label="Mostrar contraseña"
|
||||
tabIndex={-1}
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<FontAwesomeIcon icon={show ? faEyeSlash : faEye} className='fa-lg' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordInput;
|
||||
18
src/components/Auth/ProtectedRoute.jsx
Normal file
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;
|
||||
20
src/components/CardGrid.jsx
Normal file
20
src/components/CardGrid.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import LoadingIcon from './LoadingIcon';
|
||||
|
||||
const CardGrid = ({
|
||||
items = [],
|
||||
renderCard,
|
||||
loaderRef,
|
||||
loading = false
|
||||
}) => {
|
||||
return (
|
||||
<div className="cards-grid">
|
||||
{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 CardGrid;
|
||||
47
src/components/CustomCarousel.jsx
Normal file
47
src/components/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,
|
||||
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;
|
||||
26
src/components/CustomModal.jsx
Normal file
26
src/components/CustomModal.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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, children }) => {
|
||||
return (
|
||||
<Modal show={show} onHide={onClose} size="md" centered>
|
||||
<Modal.Header className='justify-content-between'>
|
||||
<Modal.Title>{title}</Modal.Title>
|
||||
<Button variant='transparent' onClick={onClose}>
|
||||
<FontAwesomeIcon icon={faXmark} className='close-button fa-xl' />
|
||||
</Button>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="p-0"
|
||||
style={{
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
{children}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomModal;
|
||||
60
src/components/File.jsx
Normal file
60
src/components/File.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { faTrashAlt } from "@fortawesome/free-regular-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Card, Button, OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||
import '@/css/File.css';
|
||||
|
||||
const File = ({ file, onDelete }) => {
|
||||
const getIcon = (type) => {
|
||||
const dir = "/images/icons/filetype/";
|
||||
switch (type) {
|
||||
case "image/jpeg":
|
||||
return dir + "jpg_64.svg";
|
||||
case "image/png":
|
||||
return dir + "png_64.svg";
|
||||
case "video/mp4":
|
||||
return dir + "mp4_64.svg";
|
||||
case "application/pdf":
|
||||
return dir + "pdf_64.svg";
|
||||
case "text/plain":
|
||||
return dir + "txt_64.svg";
|
||||
default:
|
||||
return dir + "file_64.svg";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="file-card col-sm-3 col-lg-2 col-xxl-1 m-0 p-0 position-relative text-decoration-none bg-transparent"
|
||||
onClick={() => window.open(`https://miarma.net/files/huertos/${file.file_name}`, "_blank")}
|
||||
>
|
||||
<Card.Body className="text-center">
|
||||
<img
|
||||
src={getIcon(file.mime_type)}
|
||||
alt={file.file_name}
|
||||
className="img-fluid mb-2"
|
||||
/>
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={<Tooltip>{file.file_name}</Tooltip>}
|
||||
>
|
||||
<p className="m-0 p-0 text-truncate">{file.file_name}</p>
|
||||
</OverlayTrigger>
|
||||
</Card.Body>
|
||||
|
||||
<Button
|
||||
variant="transparent"
|
||||
size="md"
|
||||
color="text-danger"
|
||||
className="delete-btn position-absolute top-0 end-0 m-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(file);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrashAlt} />
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default File;
|
||||
105
src/components/FileUpload.jsx
Normal file
105
src/components/FileUpload.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import { Card, CloseButton } from "react-bootstrap";
|
||||
import "@/css/FileUpload.css";
|
||||
|
||||
const MAX_FILE_SIZE_MB = 10;
|
||||
|
||||
const FileUpload = forwardRef(({ onFilesSelected }, ref) => {
|
||||
const fileInputRef = useRef();
|
||||
const [highlight, setHighlight] = useState(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState([]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetSelectedFiles: () => {
|
||||
setSelectedFiles([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = null; // limpia input real
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
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 (
|
||||
<Card
|
||||
className={`upload-card shadow-sm mb-4 ${highlight ? "highlight" : ""}`}
|
||||
onClick={openFileDialog}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
role="button"
|
||||
>
|
||||
<Card.Body className="text-center">
|
||||
<h2 className="mb-3">📎 Subir archivo</h2>
|
||||
<p>
|
||||
Arrastra o haz click para seleccionar archivos (Máx. 10MB)
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.webp"
|
||||
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>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileUpload;
|
||||
13
src/components/FloatingMenu/AddMovieButton.jsx
Normal file
13
src/components/FloatingMenu/AddMovieButton.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import "@/css/FloatingMenuButton.css";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
const AddMovieButton = () => {
|
||||
return (
|
||||
<button className="floating-menu-button">
|
||||
<FontAwesomeIcon icon={faPlus} className="fa-2x" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddMovieButton;
|
||||
13
src/components/FloatingMenu/AddUserButton.jsx
Normal file
13
src/components/FloatingMenu/AddUserButton.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import "@/css/FloatingMenuButton.css";
|
||||
import { faUserPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
const AddUserButton = () => {
|
||||
return (
|
||||
<button className="floating-menu-button">
|
||||
<FontAwesomeIcon icon={faUserPlus} className="fa-lg" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddUserButton;
|
||||
189
src/components/FloatingMenu/FloatingMenu.jsx
Normal file
189
src/components/FloatingMenu/FloatingMenu.jsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useState } from "react";
|
||||
import { motion as _motion, AnimatePresence } from "framer-motion";
|
||||
import "@/css/FloatingMenu.css";
|
||||
import { faEllipsisVertical } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import AddMovieModal from "../Movies/AddMovieModal";
|
||||
import AddUserModal from "../Users/AddUserModal";
|
||||
import { useData } from "@/hooks/useData";
|
||||
import { useConfig } from "@/hooks/useConfig";
|
||||
import LoadingIcon from "@/components/LoadingIcon";
|
||||
import AddMovieButton from "./AddMovieButton";
|
||||
import AddUserButton from "./AddUserButton";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import NotificationModal from "@/components/NotificationModal";
|
||||
|
||||
const FloatingMenu = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [movieModal, setMovieModal] = useState(null);
|
||||
const [userModal, setUserModal] = useState(null);
|
||||
const [postNotifModal, setPostNotifModal] = useState(false);
|
||||
const [newUserName, setNewUserName] = useState("");
|
||||
const { postData } = useData();
|
||||
const location = useLocation();
|
||||
|
||||
const { config, configLoading } = useConfig();
|
||||
if (configLoading) return <p><LoadingIcon /></p>;
|
||||
|
||||
const uploadUrl = `${config.apiConfig.coreRawUrl}${config.apiConfig.endpoints.files.upload}`;
|
||||
const moviesUrl = `${config.apiConfig.baseRawUrl}${config.apiConfig.endpoints.movies.getAll}`;
|
||||
|
||||
const buttonVariants = {
|
||||
hidden: { opacity: 0, y: 10 },
|
||||
visible: (i) => ({
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { delay: i * 0.05, type: "spring", stiffness: 300 }
|
||||
}),
|
||||
exit: { opacity: 0, y: 10, transition: { duration: 0.1 } }
|
||||
};
|
||||
|
||||
let buttons = [];
|
||||
|
||||
if (location.pathname.includes("/votar")) {
|
||||
buttons.push({
|
||||
component: <AddMovieButton />,
|
||||
key: "add-movie",
|
||||
onClick: () => setMovieModal(true)
|
||||
});
|
||||
}
|
||||
|
||||
if (location.pathname.includes("/usuarios")) {
|
||||
buttons.push({
|
||||
component: <AddUserButton />,
|
||||
key: "add-user",
|
||||
onClick: () => setUserModal(true)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const sanitizeForSQL = (str) => {
|
||||
if (typeof str !== "string") return "";
|
||||
|
||||
return str
|
||||
.trim()
|
||||
.replace(/\s+/g, " ") // quita saltos de línea y dobles espacios
|
||||
.replace(/\\/g, "\\\\") // escapa \
|
||||
.replace(/'/g, "\\'") // escapa '
|
||||
.replace(/"/g, '\\"'); // escapa "
|
||||
};
|
||||
|
||||
const handleMovieSubmit = async (data) => {
|
||||
|
||||
// Lógica subir portada =================
|
||||
const file = data.coverFile;
|
||||
const file_name = file.name;
|
||||
const mime_type = file.type || "application/octet-stream";
|
||||
const uploaded_by = JSON.parse(localStorage.getItem("user"))?.user_id;
|
||||
const context = 3;
|
||||
|
||||
const fileFormData = new FormData();
|
||||
fileFormData.append("file", file);
|
||||
fileFormData.append("file_name", file_name);
|
||||
fileFormData.append("mime_type", mime_type);
|
||||
fileFormData.append("uploaded_by", uploaded_by);
|
||||
fileFormData.append("context", context);
|
||||
|
||||
try {
|
||||
await postData(uploadUrl, fileFormData);
|
||||
} catch (err) {
|
||||
console.error("Error al subir archivo:", err);
|
||||
}
|
||||
// ====================================
|
||||
|
||||
let coverUrl = `https://miarma.net/files/cine/${file_name}`;
|
||||
const cleanTitle = sanitizeForSQL(data.title);
|
||||
const cleanDescription = sanitizeForSQL(data.description);
|
||||
|
||||
try {
|
||||
await postData(moviesUrl, {
|
||||
title: cleanTitle,
|
||||
description: cleanDescription,
|
||||
cover: coverUrl
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error al añadir película:", err);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const handleUserSubmit = async (data) => {
|
||||
const userData = {
|
||||
display_name: sanitizeForSQL(data.display_name),
|
||||
password: data.password,
|
||||
status: data.status,
|
||||
role: data.role,
|
||||
global_status: data.global_status,
|
||||
global_role: data.global_role
|
||||
};
|
||||
|
||||
try {
|
||||
const postResponse = await postData(
|
||||
`${config.apiConfig.baseRawUrl}${config.apiConfig.endpoints.viewers.getAll}`,
|
||||
userData
|
||||
);
|
||||
|
||||
const newUserName = postResponse?.user_name || "usuario";
|
||||
setNewUserName(newUserName);
|
||||
setPostNotifModal(true);
|
||||
setUserModal(false);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error al añadir usuario:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="floating-menu">
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<_motion.div
|
||||
className="menu-buttons"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="hidden"
|
||||
>
|
||||
{buttons.map((btn, i) => (
|
||||
<_motion.div
|
||||
key={btn.key}
|
||||
custom={i}
|
||||
variants={buttonVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
onClick={btn.onClick}
|
||||
>
|
||||
{btn.component}
|
||||
</_motion.div>
|
||||
))}
|
||||
</_motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AddMovieModal show={movieModal} onClose={() => setMovieModal(false)} onSubmit={handleMovieSubmit} />
|
||||
<AddUserModal show={userModal} onClose={() => setUserModal(false)} onSubmit={handleUserSubmit} />
|
||||
|
||||
<button className="menu-toggle" onClick={() => setOpen(prev => !prev)}>
|
||||
<FontAwesomeIcon icon={faEllipsisVertical} className="fa-2x" />
|
||||
</button>
|
||||
</div>
|
||||
<NotificationModal
|
||||
show={postNotifModal}
|
||||
onClose={() => setPostNotifModal(false)}
|
||||
title="Usuario añadido"
|
||||
message={`El usuario ${newUserName} ha sido añadido correctamente`}
|
||||
variant="success"
|
||||
buttons={[
|
||||
{
|
||||
label: 'Aceptar',
|
||||
variant: 'success',
|
||||
onClick: () => setPostNotifModal(false)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingMenu;
|
||||
14
src/components/FloatingMenu/ThemeButton.jsx
Normal file
14
src/components/FloatingMenu/ThemeButton.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import "@/css/ThemeButton.css";
|
||||
|
||||
const ThemeButton = () => {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button className="theme-toggle" onClick={toggleTheme}>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThemeButton;
|
||||
13
src/components/Footer.jsx
Normal file
13
src/components/Footer.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer>
|
||||
<div className="container mx-auto text-center mt-5">
|
||||
<p className="text-xs mt-2">
|
||||
Hecho con ❤️ por <a href="https://gallardo.dev">Gallardo7761</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
59
src/components/Header.jsx
Normal file
59
src/components/Header.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import '@/css/Header.css';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Navbar from "@/components/Navbar";
|
||||
import IfAuthenticated from "@/components/Auth/IfAuthenticated";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChartColumn, faSignOut, faUsers } from '@fortawesome/free-solid-svg-icons';
|
||||
import IfRole from './Auth/IfRole';
|
||||
import { CONSTANTS } from '@/util/constants';
|
||||
|
||||
const Header = () => {
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={`text-center header p-4 d-flex flex-column justify-content-center align-items-center`}>
|
||||
<Link to='/' className='text-decoration-none'>
|
||||
<h1>Huertos de Cine</h1>
|
||||
</Link>
|
||||
</header>
|
||||
<IfAuthenticated>
|
||||
<Navbar
|
||||
rightContent={
|
||||
<Link to="/login" onClick={handleLogout} className="nav-link p-0">
|
||||
<FontAwesomeIcon icon={faSignOut} className="me-2" />
|
||||
Cerrar sesión
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<li className="nav-item user-name nav-link p-0">{`@${JSON.parse(localStorage.getItem("user"))?.user_name}`}</li>
|
||||
<li className="nav-item">
|
||||
<Link to="/votar" className="nav-link p-0">
|
||||
<FontAwesomeIcon icon={faChartColumn} className="me-2" />
|
||||
votos
|
||||
</Link>
|
||||
</li>
|
||||
<IfRole roles={[CONSTANTS.ROLE_ADMIN]}>
|
||||
<li className="nav-item">
|
||||
<Link to="/usuarios" className="nav-link p-0">
|
||||
<FontAwesomeIcon icon={faUsers} className="me-2" />
|
||||
usuarios
|
||||
</Link>
|
||||
</li>
|
||||
</IfRole>
|
||||
</Navbar>
|
||||
|
||||
</IfAuthenticated>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
10
src/components/LoadingIcon.jsx
Normal file
10
src/components/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;
|
||||
113
src/components/Movies/AddMovieModal.jsx
Normal file
113
src/components/Movies/AddMovieModal.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState, useRef } from "react";
|
||||
import CustomModal from "@/components/CustomModal";
|
||||
import FileUpload from "@/components/FileUpload";
|
||||
import { Form, Button, Alert } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faAlignCenter, faCancel, faImage, faPenFancy, faSave } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const AddMovieModal = ({ show, onClose, onSubmit }) => {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [files, setFiles] = useState([]);
|
||||
const [errors, setErrors] = useState(null);
|
||||
const fileUploadRef = useRef();
|
||||
|
||||
const handleSubmit = () => {
|
||||
const validationErrors = [];
|
||||
if (!title.trim()) validationErrors.push("El título es obligatorio.");
|
||||
if (!description.trim()) validationErrors.push("La descripción es obligatoria.");
|
||||
if (files.length === 0) validationErrors.push("Debes subir una portada.");
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors(null);
|
||||
const formData = {
|
||||
title,
|
||||
description,
|
||||
coverFile: files[0], // Solo 1 portada
|
||||
};
|
||||
|
||||
onSubmit?.(formData);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setFiles([]);
|
||||
fileUploadRef.current?.resetSelectedFiles();
|
||||
setErrors(null);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomModal show={show} onClose={handleClose} title="Añadir película">
|
||||
<div className="p-3">
|
||||
<Form>
|
||||
{errors && (
|
||||
<Alert variant="danger">
|
||||
<ul className="mb-0">
|
||||
{errors.map((err, idx) => (
|
||||
<li key={idx}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form.Group className="mb-3" controlId="formTitle">
|
||||
<Form.Label>
|
||||
<FontAwesomeIcon icon={faPenFancy} className="me-2" />
|
||||
Título
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="Introduce el título"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="themed-input rounded-4"
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3" controlId="formDescription">
|
||||
<Form.Label>
|
||||
<FontAwesomeIcon icon={faAlignCenter} className="me-2" />
|
||||
Descripción
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={3}
|
||||
placeholder="Introduce una descripción"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="themed-input rounded-4"
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>
|
||||
<FontAwesomeIcon icon={faImage} className="me-2" />
|
||||
Portada
|
||||
</Form.Label>
|
||||
<FileUpload ref={fileUploadRef} onFilesSelected={setFiles} />
|
||||
</Form.Group>
|
||||
|
||||
<div className="d-flex justify-content-end mt-4">
|
||||
<Button variant="danger" onClick={handleClose} className="me-2">
|
||||
<FontAwesomeIcon icon={faCancel} className="me-2" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="warning" onClick={handleSubmit}>
|
||||
<FontAwesomeIcon icon={faSave} className="me-2" />
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</CustomModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddMovieModal;
|
||||
310
src/components/Movies/MovieCard.jsx
Normal file
310
src/components/Movies/MovieCard.jsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import '@/css/MovieCard.css';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import CustomModal from '../CustomModal';
|
||||
import { faAlignCenter, faCancel, faEdit, faImage, faPenFancy, faSave, faThumbsDown, faThumbsUp, faTrash } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { useData } from '@/hooks/useData';
|
||||
import { useConfig } from '@/hooks/useConfig';
|
||||
import { Button, Form, Alert } from 'react-bootstrap';
|
||||
import FileUpload from '@/components/FileUpload';
|
||||
import IfRole from '../Auth/IfRole';
|
||||
import { CONSTANTS } from '@/util/constants';
|
||||
|
||||
const MovieCard = ({ movie_id, title, description, cover }) => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const [editModal, setEditModal] = useState(false);
|
||||
const [votes, setVotes] = useState(0);
|
||||
const [userVote, setUserVote] = useState(null); // 'up', 'down' o null
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const { getData, putData, postData, deleteData, deleteDataWithBody } = useData();
|
||||
const { config } = useConfig();
|
||||
const userId = JSON.parse(localStorage.getItem('user') || '{}')?.user_id;
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) return;
|
||||
|
||||
const fetchVotes = async () => {
|
||||
try {
|
||||
const url = `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.movies.getVotes}`.replace(':movie_id', movie_id);
|
||||
const response = await getData(url);
|
||||
|
||||
const votesTotal = response.data.reduce((acc, v) => acc + v.vote, 0);
|
||||
setVotes(votesTotal);
|
||||
|
||||
const myVote = response.data.find(v => v.user_id === userId)?.vote;
|
||||
setUserVote(myVote === 1 ? 'up' : myVote === -1 ? 'down' : null);
|
||||
} catch (error) {
|
||||
console.error('Error fetching votes:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVotes();
|
||||
}, [movie_id, getData, config, userId]);
|
||||
|
||||
const sendVote = async (type) => {
|
||||
if (!config) return;
|
||||
|
||||
const voteValue = type === 'up' ? 1 : -1;
|
||||
const url = `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.movies.getVotes}`.replace(':movie_id', movie_id);
|
||||
|
||||
try {
|
||||
await postData(url, { user_id: userId, vote: voteValue });
|
||||
|
||||
let delta = voteValue;
|
||||
if (userVote === 'up' && type === 'down') delta = -2;
|
||||
else if (userVote === 'down' && type === 'up') delta = 2;
|
||||
|
||||
setVotes(v => v + delta);
|
||||
setUserVote(type);
|
||||
} catch (err) {
|
||||
console.error('Error al votar:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnvote = async () => {
|
||||
if (!config) return;
|
||||
|
||||
const url = `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.movies.getVotes}`.replace(':movie_id', movie_id);
|
||||
try {
|
||||
await deleteDataWithBody(url, { user_id: userId });
|
||||
setVotes(v => v + (userVote === 'up' ? -1 : 1));
|
||||
setUserVote(null);
|
||||
} catch (err) {
|
||||
console.error('Error al quitar voto:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoteClick = (type) => (userVote === type ? handleUnvote() : sendVote(type));
|
||||
|
||||
const handleDelete = () => {
|
||||
setDeleteTarget(movie_id);
|
||||
}
|
||||
|
||||
const handleEdit = async (formData) => {
|
||||
const editUrl = `${config.apiConfig.baseRawUrl}${config.apiConfig.endpoints.movies.getById}`.replace(':movie_id', movie_id);
|
||||
|
||||
let coverUrl = cover;
|
||||
|
||||
if (formData.coverFile) {
|
||||
// === Lógica de subida de archivo ===
|
||||
const file = formData.coverFile;
|
||||
const file_name = file.name;
|
||||
const mime_type = file.type || "application/octet-stream";
|
||||
const uploaded_by = JSON.parse(localStorage.getItem("user"))?.user_id;
|
||||
const context = 3;
|
||||
|
||||
const fileFormData = new FormData();
|
||||
fileFormData.append("file", file);
|
||||
fileFormData.append("file_name", file_name);
|
||||
fileFormData.append("mime_type", mime_type);
|
||||
fileFormData.append("uploaded_by", uploaded_by);
|
||||
fileFormData.append("context", context);
|
||||
|
||||
const uploadUrl = `${config.apiConfig.coreRawUrl}${config.apiConfig.endpoints.files.upload}`;
|
||||
|
||||
try {
|
||||
await postData(uploadUrl, fileFormData);
|
||||
coverUrl = `https://miarma.net/files/cine/${file_name}`;
|
||||
} catch (err) {
|
||||
console.error("Error al subir archivo:", err);
|
||||
return; // no sigas si el archivo ha fallado
|
||||
}
|
||||
// =====================================
|
||||
}
|
||||
|
||||
const data = {
|
||||
movie_id,
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
cover: coverUrl,
|
||||
};
|
||||
|
||||
try {
|
||||
await putData(editUrl, data);
|
||||
} catch (err) {
|
||||
console.error("Error al editar la película:", err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="movie-card rounded-4 card m-0 p-0 col-md-4 col-xl-2 shadow-sm">
|
||||
<IfRole roles={[CONSTANTS.ROLE_ADMIN]}>
|
||||
<div className="d-flex m-0 p-0 position-absolute top-0 end-0">
|
||||
<button className="btn btn-primary edit-button"
|
||||
onClick={() => setEditModal(true)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} className='fa-lg' />
|
||||
</button>
|
||||
<button className="btn btn-danger delete-button"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTrash} className='fa-lg' />
|
||||
</button>
|
||||
</div>
|
||||
</IfRole>
|
||||
<img
|
||||
src={cover}
|
||||
alt={`Cartel de ${title}`}
|
||||
onClick={() => setModal(true)}
|
||||
className="rounded-top-4"
|
||||
/>
|
||||
<div className="card-footer movie-vote rounded-bottom-4">
|
||||
<div className="px-3">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<span
|
||||
onClick={e => { e.stopPropagation(); handleVoteClick('up'); }}
|
||||
className={`vote-button ${userVote === 'up' ? 'active' : ''}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faThumbsUp} />
|
||||
</span>
|
||||
<span className="vote-count">{votes || 0}</span>
|
||||
<span
|
||||
onClick={e => { e.stopPropagation(); handleVoteClick('down'); }}
|
||||
className={`vote-button ${userVote === 'down' ? 'active' : ''}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faThumbsDown} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CustomModal show={modal} onClose={() => setModal(false)} title={title}>
|
||||
<div className="p-3 movie-description">
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</CustomModal>
|
||||
|
||||
<CustomModal
|
||||
title="Confirmar eliminación"
|
||||
show={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
>
|
||||
<p className='p-3'>¿Estás seguro de que quieres eliminar la película?</p>
|
||||
<div className="d-flex justify-content-end gap-2 mt-3 p-3">
|
||||
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>Cancelar</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteData(`${config.apiConfig.baseRawUrl}${config.apiConfig.endpoints.movies.getById}`.replace(':movie_id', deleteTarget));
|
||||
setDeleteTarget(null);
|
||||
} catch (err) {
|
||||
console.error("Error al eliminar:", err.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Confirmar
|
||||
</Button>
|
||||
</div>
|
||||
</CustomModal>
|
||||
|
||||
<CustomModal show={editModal} onClose={() => setEditModal(false)} title="Editar película">
|
||||
<EditMovieForm
|
||||
initialTitle={title}
|
||||
initialDescription={description}
|
||||
initialCover={cover}
|
||||
onSubmit={(formData) => {
|
||||
handleEdit(formData);
|
||||
setEditModal(false);
|
||||
}}
|
||||
onCancel={() => setEditModal(false)}
|
||||
/>
|
||||
</CustomModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const EditMovieForm = ({ initialTitle, initialDescription, initialCover, onSubmit, onCancel }) => {
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
const [description, setDescription] = useState(initialDescription);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [errors, setErrors] = useState(null);
|
||||
const fileUploadRef = useRef();
|
||||
|
||||
const handleSubmit = () => {
|
||||
const validationErrors = [];
|
||||
if (!title.trim()) validationErrors.push("El título es obligatorio.");
|
||||
if (!description.trim()) validationErrors.push("La descripción es obligatoria.");
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors(null);
|
||||
const formData = {
|
||||
title,
|
||||
description,
|
||||
coverFile: files[0] || null, // Solo mandas si cambió
|
||||
};
|
||||
|
||||
onSubmit?.(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3">
|
||||
<Form>
|
||||
{errors && (
|
||||
<Alert variant="danger">
|
||||
<ul className="mb-0">
|
||||
{errors.map((err, idx) => (
|
||||
<li key={idx}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form.Group className="mb-3" controlId="formTitle">
|
||||
<Form.Label>
|
||||
<FontAwesomeIcon icon={faPenFancy} className="me-2" />
|
||||
Título
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="themed-input rounded-4"
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3" controlId="formDescription">
|
||||
<Form.Label>
|
||||
<FontAwesomeIcon icon={faAlignCenter} className="me-2" />
|
||||
Descripción
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="themed-input rounded-4"
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>
|
||||
<FontAwesomeIcon icon={faImage} className="me-2" />
|
||||
Nueva portada (opcional)
|
||||
</Form.Label>
|
||||
<FileUpload ref={fileUploadRef} onFilesSelected={setFiles} />
|
||||
</Form.Group>
|
||||
|
||||
<div className="d-flex justify-content-end mt-4">
|
||||
<Button variant="danger" onClick={onCancel} className="me-2">
|
||||
<FontAwesomeIcon icon={faCancel} className="me-2" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="warning" onClick={handleSubmit}>
|
||||
<FontAwesomeIcon icon={faSave} className="me-2" />
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default MovieCard;
|
||||
61
src/components/Movies/MovieCardMobile.jsx
Normal file
61
src/components/Movies/MovieCardMobile.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import '@/css/MovieCard.css';
|
||||
import VoteButtons from '@/components/Movies/VoteButtons.jsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const MovieCardMobile = ({ title, description, cover }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (description.length > 400 && !expanded) {
|
||||
setExpanded(false);
|
||||
}
|
||||
}, [description, expanded]);
|
||||
|
||||
return (
|
||||
<div className="movie-card movie-card-mobile shadow-sm mb-3">
|
||||
<div className="row w-100">
|
||||
<img
|
||||
src={cover}
|
||||
alt={`Cartel de ${title}`}
|
||||
className="img-fluid w-100 movie-card-img rounded-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="row g-0 p-2">
|
||||
<div className="col-1 d-flex flex-column align-items-center">
|
||||
<VoteButtons />
|
||||
</div>
|
||||
|
||||
<div className="col-11 ps-2">
|
||||
<h2 className="movie-title fs-5 mb-2">{title}</h2>
|
||||
<p className="movie-description mb-2">
|
||||
{
|
||||
expanded
|
||||
? description
|
||||
: (description.length > 400 ? `${description.slice(0, 400)}...` : description)
|
||||
}
|
||||
</p>
|
||||
{
|
||||
description.length > 400 && (
|
||||
<button
|
||||
className="btn btn-outline-info btn-sm"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? 'Ver menos' : 'Ver más'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
MovieCardMobile.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
description: PropTypes.string.isRequired,
|
||||
cover: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default MovieCardMobile;
|
||||
58
src/components/NavBar.jsx
Normal file
58
src/components/NavBar.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import PropTypes from 'prop-types';
|
||||
import '@/css/Navbar.css';
|
||||
|
||||
const _motion = motion;
|
||||
|
||||
const NavBar = ({ children, rightContent }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const navVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { staggerChildren: 0.1 } }
|
||||
};
|
||||
|
||||
const toggleNavbar = () => setIsOpen(!isOpen);
|
||||
|
||||
return (
|
||||
<nav className="navbar navbar-expand-lg sticky-top navbar-dark shadow-sm py-3">
|
||||
<div className="container">
|
||||
<button
|
||||
className="navbar-toggler"
|
||||
type="button"
|
||||
aria-controls="navbarContent"
|
||||
aria-expanded={isOpen}
|
||||
aria-label="Toggle navigation"
|
||||
onClick={toggleNavbar}
|
||||
>
|
||||
<span className="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<_motion.div
|
||||
className={`collapse navbar-collapse ${isOpen ? 'show' : ''}`}
|
||||
id="navbarContent"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={navVariants}
|
||||
>
|
||||
<ul className="navbar-nav me-auto mb-2 mb-lg-0 d-flex align-items-center gap-3">
|
||||
{children}
|
||||
</ul>
|
||||
{rightContent && (
|
||||
<div className="navbar-nav d-flex ms-auto align-items-center">
|
||||
{rightContent}
|
||||
</div>
|
||||
)}
|
||||
</_motion.div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
NavBar.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
rightContent: PropTypes.node,
|
||||
};
|
||||
|
||||
export default NavBar;
|
||||
69
src/components/NotificationModal.jsx
Normal file
69
src/components/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;
|
||||
15
src/components/SearchToolbar.jsx
Normal file
15
src/components/SearchToolbar.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
const SearchToolbar = ({ searchTerm, onSearchChange }) => (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SearchToolbar;
|
||||
166
src/components/Users/AddUserModal.jsx
Normal file
166
src/components/Users/AddUserModal.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useState } from "react";
|
||||
import CustomModal from "@/components/CustomModal";
|
||||
import { Form, Button, Alert } from "react-bootstrap";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faCancel,
|
||||
faSave,
|
||||
faEye,
|
||||
faKey,
|
||||
faEyeSlash,
|
||||
faDice
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
const AddViewerModal = ({ show, onClose, onSubmit }) => {
|
||||
const [viewer, setViewer] = useState({
|
||||
display_name: "",
|
||||
password: "",
|
||||
status: 1,
|
||||
role: 0,
|
||||
global_status: 1,
|
||||
global_role: 0
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const toggleShowPassword = () => setShowPassword((v) => !v);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setViewer((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const generateRandomPassword = (length = 12) => {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_-+=<>?";
|
||||
let pass = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
pass += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return pass;
|
||||
};
|
||||
|
||||
const handleGeneratePassword = () => {
|
||||
const newPass = generateRandomPassword();
|
||||
setViewer((prev) => ({ ...prev, password: newPass }));
|
||||
setShowPassword(true);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const validationErrors = [];
|
||||
const { display_name, password } = viewer;
|
||||
|
||||
if (!display_name.trim()) validationErrors.push("El nombre para mostrar es obligatorio.");
|
||||
if (!password.trim()) validationErrors.push("La contraseña es obligatoria.");
|
||||
if (password.length < 6) validationErrors.push("La contraseña debe tener al menos 6 caracteres.");
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
setErrors(validationErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setErrors(null);
|
||||
onSubmit?.(viewer);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setViewer({
|
||||
display_name: "",
|
||||
password: "",
|
||||
status: 1,
|
||||
role: 0,
|
||||
global_status: 1,
|
||||
global_role: 0
|
||||
});
|
||||
setErrors(null);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomModal show={show} onClose={handleClose} title="Añadir usuario">
|
||||
<div className="p-3">
|
||||
<Form>
|
||||
{errors && (
|
||||
<Alert variant="danger">
|
||||
<ul className="mb-0">
|
||||
{errors.map((err, idx) => (
|
||||
<li key={idx}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>
|
||||
<FontAwesomeIcon icon={faEye} className="me-2" />
|
||||
Nombre para mostrar
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
name="display_name"
|
||||
value={viewer.display_name}
|
||||
onChange={e => {e.target.value = e.target.value.toUpperCase(); handleChange(e);}}
|
||||
className="themed-input rounded-4"
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
{/* Password input con toggle show/hide */}
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label className="fw-semibold">
|
||||
<FontAwesomeIcon icon={faKey} className="me-2" />
|
||||
Contraseña
|
||||
</Form.Label>
|
||||
<div className="position-relative">
|
||||
<Form.Control
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
value={viewer.password}
|
||||
placeholder="Escribe tu contraseña"
|
||||
onChange={handleChange}
|
||||
className="rounded-4 pe-5 themed-input"
|
||||
/>
|
||||
<div className="d-flex h-100 align-items-center gap-2 m-0 me-3 p-0 position-absolute end-0 top-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="show-button h-100 p-0"
|
||||
onClick={handleGeneratePassword}
|
||||
aria-label="Generar contraseña aleatoria"
|
||||
tabIndex={-1}
|
||||
style={{ zIndex: 2, width: "2.5rem" }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDice} className="fa-lg" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="show-button h-100 p-0"
|
||||
onClick={toggleShowPassword}
|
||||
aria-label={showPassword ? "Ocultar contraseña" : "Mostrar contraseña"}
|
||||
tabIndex={-1}
|
||||
style={{ zIndex: 2 }}
|
||||
>
|
||||
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} className="fa-lg" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Group>
|
||||
|
||||
<div className="d-flex justify-content-end mt-4">
|
||||
<Button variant="danger" onClick={handleClose} className="me-2">
|
||||
<FontAwesomeIcon icon={faCancel} className="me-2" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="success" onClick={handleSubmit}>
|
||||
<FontAwesomeIcon icon={faSave} className="me-2" />
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</CustomModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddViewerModal;
|
||||
30
src/components/Users/UserCard.jsx
Normal file
30
src/components/Users/UserCard.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import '@/css/UserCard.css';
|
||||
import { faTrashCan, faUserPlus } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
||||
const UserCard = ({ renderMode, user, onAdd, onDelete }) => {
|
||||
return (
|
||||
<div className="col-12 col-sm-6 col-md-4 col-lg-3 my-2 p-1">
|
||||
<div className="card rounded-4 user-card h-100">
|
||||
<div className="card-body d-flex justify-content-between align-items-center">
|
||||
<h5 className="card-title m-0">{user.display_name}</h5>
|
||||
<div className="m-0 p-0">
|
||||
{renderMode === 'add' ? (
|
||||
<button className="btn btn-link text-success delete-button m-0 p-0" onClick={onAdd}>
|
||||
<FontAwesomeIcon icon={faUserPlus} className="fa-lg" />
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-link text-danger delete-button m-0 p-0" onClick={onDelete}>
|
||||
<FontAwesomeIcon icon={faTrashCan} className="fa-lg" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
export default UserCard;
|
||||
98
src/context/AuthContext.jsx
Normal file
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 AUTH_URL = config.apiConfig.authUrl;
|
||||
const VALIDATE_URL = `${AUTH_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, tokenTime, loggedUser } = 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
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("/config/settings.prod.json")
|
||||
: await fetch("/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
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,
|
||||
};
|
||||
31
src/context/ThemeContext.jsx
Normal file
31
src/context/ThemeContext.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
|
||||
export const ThemeContext = createContext();
|
||||
|
||||
export const ThemeProvider = ({ children }) => {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
return (
|
||||
localStorage.getItem("theme") ||
|
||||
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
document.body.classList.remove("light", "dark");
|
||||
document.body.classList.add(theme);
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
28
src/css/AnimatedDropdown.css
Normal file
28
src/css/AnimatedDropdown.css
Normal file
@@ -0,0 +1,28 @@
|
||||
.dropdown-menu .dropdown-divider {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
background-color: var(--bg-color) !important;
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
.dropdown-menu.show {
|
||||
background-color: var(--navbar-bg) !important;
|
||||
box-shadow: 0 5px 10px var(--shadow-color);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
background-color: var(--navbar-bg) !important;
|
||||
color: var(--navbar-dropdown-item-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--navbar-bg) !important;
|
||||
color: var(--secondary-color) !important;
|
||||
}
|
||||
|
||||
.disabled.text-muted {
|
||||
color: var(--muted-color) !important;
|
||||
}
|
||||
11
src/css/CustomCarousel.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;
|
||||
}
|
||||
41
src/css/File.css
Normal file
41
src/css/File.css
Normal file
@@ -0,0 +1,41 @@
|
||||
.file-card {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.file-card .card-body {
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
|
||||
color: var(--selective-yellow);
|
||||
background-color: var(--cocoa-brown-light-1);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-card:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.file-card img {
|
||||
max-width: 48px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.file-card p {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
color: var(--selective-yellow);
|
||||
}
|
||||
|
||||
.file-card .delete-btn {
|
||||
font-size: 1.2rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
color: white;
|
||||
|
||||
}
|
||||
|
||||
.file-card .delete-btn:hover {
|
||||
color: var(--bs-danger);
|
||||
}
|
||||
|
||||
33
src/css/FileUpload.css
Normal file
33
src/css/FileUpload.css
Normal file
@@ -0,0 +1,33 @@
|
||||
.upload-card {
|
||||
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border: 2px dashed var(--selective-yellow) !important;
|
||||
background-color: var(--cocoa-brown-light-2) !important;
|
||||
color: var(--selective-yellow);
|
||||
}
|
||||
|
||||
.upload-card:hover {
|
||||
border: 2px dashed var(--selective-yellow-light) !important;
|
||||
background-color: var(--cocoa-brown-light-3) !important;
|
||||
}
|
||||
|
||||
.upload-card.highlight {
|
||||
border-color: var(--selective-yellow-light) !important;
|
||||
background-color: var(--cocoa-brown-light-3);
|
||||
}
|
||||
|
||||
.upload-card .file-list {
|
||||
margin-top: 1rem;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.upload-card .file-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--selective-yellow);
|
||||
}
|
||||
55
src/css/FloatingMenu.css
Normal file
55
src/css/FloatingMenu.css
Normal file
@@ -0,0 +1,55 @@
|
||||
.floating-menu {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.menu-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.menu-toggle {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--selective-yellow);
|
||||
color: var(--cocoa-brown);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: background-color 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.menu-toggle:hover {
|
||||
background-color: var(--selective-yellow-light);
|
||||
}
|
||||
|
||||
.menu-buttons button {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--selective-yellow);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: background-color 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.menu-buttons button:hover {
|
||||
background-color: var(--selective-yellow-light);
|
||||
}
|
||||
19
src/css/FloatingMenuButton.css
Normal file
19
src/css/FloatingMenuButton.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.floating-menu-button {
|
||||
z-index: 1000;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--selective-yellow);
|
||||
color: var(--cocoa-brown) !important;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: background-color 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.floating-menu-button:hover {
|
||||
background-color: var(--selective-yellow-light);
|
||||
}
|
||||
15
src/css/Header.css
Normal file
15
src/css/Header.css
Normal file
@@ -0,0 +1,15 @@
|
||||
.header {
|
||||
background-color: var(--selective-yellow);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 4.5em;
|
||||
font-weight: bold;
|
||||
color: var(--cocoa-brown) !important;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 1.25rem;
|
||||
color: var(--selective-yellow);
|
||||
font-weight: bold;
|
||||
}
|
||||
65
src/css/LoginForm.css
Normal file
65
src/css/LoginForm.css
Normal file
@@ -0,0 +1,65 @@
|
||||
/* ================================
|
||||
LOGIN - CARD CONTAINER (VISUAL)
|
||||
================================== */
|
||||
.login-card {
|
||||
background-color: var(--cocoa-brown-light-2) !important;
|
||||
color: var(--text-color);
|
||||
box-shadow: 0 0 10px black;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
INPUTS VISUALES
|
||||
================================== */
|
||||
input.form-control {
|
||||
background-color: var(--cocoa-brown-light-1) !important;
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
border-radius: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
input.form-control::placeholder {
|
||||
color: #ffffff80;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
LABELS PERSONALIZADAS
|
||||
================================== */
|
||||
label {
|
||||
font-family: 'Product Sans', sans-serif;
|
||||
font-size: 1.1em;
|
||||
color: var(--selective-yellow-light);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
BOTÓN VISUAL
|
||||
================================== */
|
||||
.login-button {
|
||||
font-family: 'Product Sans', sans-serif !important;
|
||||
font-size: 1.3em !important;
|
||||
font-weight: bold !important;
|
||||
background-color: var(--selective-yellow) !important;
|
||||
color: black !important;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background-color: var(--hover-color) !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
CHECKBOX / FORM CHECK
|
||||
================================== */
|
||||
.form-check-label {
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.form-check-input:checked {
|
||||
background-color: var(--selective-yellow-dark);
|
||||
border-color: var(--selective-yellow-dark);
|
||||
}
|
||||
59
src/css/MovieCard.css
Normal file
59
src/css/MovieCard.css
Normal file
@@ -0,0 +1,59 @@
|
||||
.movie-card {
|
||||
background-color: var(--cocoa-brown) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.movie-card button.delete-button {
|
||||
border-top-left-radius: 0rem !important;
|
||||
border-top-right-radius: 1rem !important;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
}
|
||||
|
||||
.movie-card button.edit-button {
|
||||
border-top-left-radius: 0rem !important;
|
||||
border-top-right-radius: 0rem !important;
|
||||
border-bottom-left-radius: 0 !important;
|
||||
border-bottom-right-radius: 0 !important;
|
||||
}
|
||||
|
||||
.movie-card:hover {
|
||||
scale: 1.01;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.movie-card img {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.movie-vote {
|
||||
background-color: var(--cocoa-brown-light-2) !important;
|
||||
font-size: 2.5rem !important;
|
||||
}
|
||||
|
||||
.vote-button {
|
||||
color: var(--selective-yellow) !important;
|
||||
}
|
||||
|
||||
.vote-button.active {
|
||||
color: #fffc9a !important;
|
||||
}
|
||||
|
||||
.vote-count {
|
||||
color: var(--selective-yellow) !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.vote-button {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.vote-button:hover {
|
||||
filter: brightness(0.75) !important;
|
||||
}
|
||||
|
||||
.movie-description {
|
||||
color: var(--text-color) !important;
|
||||
font-size: 1.1rem !important;
|
||||
line-height: 1.4 !important;
|
||||
}
|
||||
14
src/css/Navbar.css
Normal file
14
src/css/Navbar.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.navbar-nav .nav-link {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: var(--selective-yellow);
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link:hover {
|
||||
color: var(--selective-yellow-light);
|
||||
}
|
||||
|
||||
nav.navbar {
|
||||
background-color: var(--cocoa-brown);
|
||||
}
|
||||
4
src/css/NotFound.css
Normal file
4
src/css/NotFound.css
Normal file
@@ -0,0 +1,4 @@
|
||||
h1.not-found {
|
||||
font-size: 10em;
|
||||
font-weight: bold;
|
||||
}
|
||||
8
src/css/PasswordInput.css
Normal file
8
src/css/PasswordInput.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.show-button svg {
|
||||
color: var(--text-color);
|
||||
|
||||
}
|
||||
|
||||
.show-button:hover svg {
|
||||
color: var(--hover-color);
|
||||
}
|
||||
19
src/css/ThemeButton.css
Normal file
19
src/css/ThemeButton.css
Normal file
@@ -0,0 +1,19 @@
|
||||
.theme-toggle {
|
||||
z-index: 1000;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: background-color 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
5
src/css/UserCard.css
Normal file
5
src/css/UserCard.css
Normal file
@@ -0,0 +1,5 @@
|
||||
.user-card {
|
||||
background-color: var(--cocoa-brown) !important;
|
||||
border: none !important;
|
||||
color: var(--selective-yellow) !important;
|
||||
}
|
||||
4
src/css/Usuarios.css
Normal file
4
src/css/Usuarios.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.user-container {
|
||||
border: 2px solid var(--selective-yellow) !important;
|
||||
background-color: var(--cocoa-brown-light-2);
|
||||
}
|
||||
196
src/css/index.css
Normal file
196
src/css/index.css
Normal file
@@ -0,0 +1,196 @@
|
||||
:root {
|
||||
--cocoa-brown: #332027;
|
||||
--cocoa-brown-light-1: #472d36;
|
||||
--cocoa-brown-light-2: #5f444d;
|
||||
--cocoa-brown-light-3: #6d4f59;
|
||||
|
||||
--selective-yellow: #FCB500;
|
||||
--selective-yellow-light: #FFC526;
|
||||
--selective-yellow-dark: #D79600;
|
||||
|
||||
--text-color: var(--selective-yellow);
|
||||
--text-muted: #B0AFAF;
|
||||
--hover-color: var(--selective-yellow-dark);
|
||||
}
|
||||
|
||||
/* ================================
|
||||
TIPOGRAFÍA Y COLORES
|
||||
================================== */
|
||||
div,
|
||||
label,
|
||||
input,
|
||||
p,
|
||||
span,
|
||||
a,
|
||||
button {
|
||||
font-family: "Open Sans", sans-serif;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: "Product Sans", sans-serif;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--cocoa-brown-light-1);
|
||||
}
|
||||
|
||||
hr {
|
||||
border: dashed 0.075rem white;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
INPUTS Y CAMPOS INTERACTIVOS
|
||||
================================== */
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background-color: var(--cocoa-brown-light-2) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
input.themed-input,
|
||||
textarea.themed-input,
|
||||
select.themed-input {
|
||||
background-color: var(--cocoa-brown-light-2) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
input.themed-input::placeholder,
|
||||
textarea.themed-input::placeholder {
|
||||
color: var(--text-muted) !important;
|
||||
font-style: normal !important;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
ENFOQUE / FOCUS VISUAL
|
||||
================================== */
|
||||
textarea:focus,
|
||||
input[type="text"]:focus,
|
||||
input[type="password"]:focus,
|
||||
input[type="datetime"]:focus,
|
||||
input[type="datetime-local"]:focus,
|
||||
input[type="date"]:focus,
|
||||
input[type="month"]:focus,
|
||||
input[type="time"]:focus,
|
||||
input[type="week"]:focus,
|
||||
input[type="number"]:focus,
|
||||
input[type="email"]:focus,
|
||||
input[type="url"]:focus,
|
||||
input[type="search"]:focus,
|
||||
input[type="tel"]:focus,
|
||||
input[type="color"]:focus,
|
||||
.uneditable-input:focus,
|
||||
select:focus,
|
||||
textarea:focus-visible,
|
||||
input:focus-visible {
|
||||
box-shadow:
|
||||
0 0 0.25rem var(--selective-yellow-dark) !important;
|
||||
outline: none !important;
|
||||
background-color: var(--cocoa-brown-light-2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
border-bottom: none !important;
|
||||
background-color: var(--cocoa-brown-light-2) !important;
|
||||
color: var(--text-color) !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--cocoa-brown-light-1) !important;
|
||||
color: var(--text-color) !important;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
border-top: none !important;
|
||||
background-color: var(--cocoa-brown-light-1) !important;
|
||||
}
|
||||
|
||||
/* ===================
|
||||
SEARCH TOOLBAR
|
||||
=================== */
|
||||
.search-toolbar-wrapper {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
z-index: 900;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--cocoa-brown-light-2) ;
|
||||
border: 2px solid var(--selective-yellow) !important;
|
||||
}
|
||||
|
||||
/* Cuando el input está enfocado */
|
||||
.search-toolbar:has(input:focus) {
|
||||
transform: scale(1.02);
|
||||
border-color: var(--selective-yellow-light) !important;
|
||||
}
|
||||
|
||||
/* Fallback si :has no es compatible */
|
||||
.search-toolbar.focused {
|
||||
transform: scale(1.02);
|
||||
border-color: var(--selective-yellow-light) !important;
|
||||
}
|
||||
|
||||
.search-toolbar input.search-input {
|
||||
all: unset;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
font-size: 1.1rem;
|
||||
font-family: "Open Sans", sans-serif;
|
||||
padding-right: 1rem;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.search-toolbar input.search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
border: none;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.search-results h3 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-results p {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cards-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
4
src/hooks/useAuth.js
Normal file
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);
|
||||
25
src/hooks/useBreakpoint.js
Normal file
25
src/hooks/useBreakpoint.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const getBreakpoint = (width) => {
|
||||
if (width < 576) return "xs";
|
||||
if (width >= 576 && width < 768) return "sm";
|
||||
if (width >= 768 && width < 992) return "md";
|
||||
if (width >= 992 && width < 1200) return "lg";
|
||||
if (width >= 1200 && width < 1400) return "xl";
|
||||
return "xxl";
|
||||
};
|
||||
|
||||
export default function useBootstrapBreakpoint() {
|
||||
const [breakpoint, setBreakpoint] = useState(getBreakpoint(window.innerWidth));
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setBreakpoint(getBreakpoint(window.innerWidth));
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return breakpoint;
|
||||
}
|
||||
4
src/hooks/useConfig.js
Normal file
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);
|
||||
135
src/hooks/useData.js
Normal file
135
src/hooks/useData.js
Normal file
@@ -0,0 +1,135 @@
|
||||
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(config); // inicializa directamente
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.baseUrl) {
|
||||
configRef.current = config; // actualiza la referencia al nuevo config
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const getAuthHeaders = () => ({
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${localStorage.getItem("token")}`,
|
||||
});
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const current = configRef.current; // usa el ref más actualizado
|
||||
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);
|
||||
}
|
||||
}, []); // <- sin dependencias porque usamos configRef y funciones puras
|
||||
|
||||
// este useEffect se ejecuta una vez al montar o cuando cambie el config.baseUrl
|
||||
useEffect(() => {
|
||||
if (config?.baseUrl) {
|
||||
fetchData(); // safe: fetchData está memoizado
|
||||
}
|
||||
}, [config?.baseUrl, fetchData]); // <- dependencia estable y limpia
|
||||
|
||||
// función pública para forzar refetch
|
||||
const refetch = () => {
|
||||
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,
|
||||
refetch, // el refetch usable desde fuera
|
||||
};
|
||||
};
|
||||
4
src/hooks/useDataContext.js
Normal file
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
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);
|
||||
}
|
||||
return result;
|
||||
}, [data, filterFn, filters, searchFn, searchTerm, sortFn]);
|
||||
|
||||
return {
|
||||
paginated: filteredData.slice(0, pageSize),
|
||||
filtered: filteredData,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filters,
|
||||
setFilters,
|
||||
loaderRef: useRef(),
|
||||
loading: false,
|
||||
hasMore: false,
|
||||
creatingItem,
|
||||
setCreatingItem,
|
||||
tempItem,
|
||||
setTempItem,
|
||||
isUsingFilters: usingSearchOrFilters,
|
||||
resetPagination: () => { }
|
||||
};
|
||||
};
|
||||
103
src/hooks/useSessionRenewal.jsx
Normal file
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/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;
|
||||
10
src/hooks/useTheme.js
Normal file
10
src/hooks/useTheme.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useContext } from "react";
|
||||
import { ThemeContext } from "../context/ThemeContext";
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme debe usarse dentro de un <ThemeProvider>");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
28
src/main.jsx
Normal file
28
src/main.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
/* COMPONENTS */
|
||||
import App from './App.jsx'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { ThemeProvider } from '@/context/ThemeContext'
|
||||
import { AuthProvider } from '@/context/AuthContext'
|
||||
import { ConfigProvider } from '@/context/ConfigContext.jsx'
|
||||
|
||||
/* CSS */
|
||||
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||
import 'bootstrap/dist/js/bootstrap.bundle.min.js'
|
||||
import '@/css/index.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<ConfigProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
11
src/pages/Login.jsx
Normal file
11
src/pages/Login.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import LoginForm from "@/components/Auth/LoginForm";
|
||||
|
||||
const Login = () => {
|
||||
return (
|
||||
<main className="container my-5">
|
||||
<LoginForm />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
16
src/pages/NotFound.jsx
Normal file
16
src/pages/NotFound.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import '@/css/NotFound.css';
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<main className="container my-5">
|
||||
<h1 className="text-center not-found">404</h1>
|
||||
<h2 className="text-center">Página no encontrada</h2>
|
||||
<Link to="/">
|
||||
<p className="text-center">Volver al inicio</p>
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFound;
|
||||
120
src/pages/Usuarios.jsx
Normal file
120
src/pages/Usuarios.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import SearchToolbar from "@/components/SearchToolbar";
|
||||
import { useState, useEffect } from "react";
|
||||
import UserCard from "@/components/Users/UserCard";
|
||||
import LoadingIcon from "@/components/LoadingIcon";
|
||||
import { DataProvider } from "@/context/DataContext";
|
||||
import { useConfig } from "@/hooks/useConfig";
|
||||
import { useDataContext } from "@/hooks/useDataContext";
|
||||
import '@/css/Usuarios.css';
|
||||
|
||||
const Usuarios = () => {
|
||||
const { config, configLoading } = useConfig();
|
||||
|
||||
if (configLoading) return <p><LoadingIcon /></p>;
|
||||
|
||||
const reqConfig = {
|
||||
baseUrl: `${config.apiConfig.baseRawUrl}${config.apiConfig.endpoints.viewers.getAll}`,
|
||||
usersUrl: `${config.apiConfig.coreRawUrl}${config.apiConfig.endpoints.users.getAll}`,
|
||||
metadataUrl: `${config?.apiConfig.baseRawUrl}${config?.apiConfig.endpoints.viewers.metadata}`,
|
||||
params: {},
|
||||
};
|
||||
|
||||
return (
|
||||
<DataProvider config={reqConfig}>
|
||||
<UsuariosContent reqConfig={reqConfig} />
|
||||
</DataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const UsuariosContent = ({ reqConfig }) => {
|
||||
const { data, dataLoading, dataError, getData, postData } = useDataContext();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await getData(reqConfig.usersUrl);
|
||||
setUsers(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsers();
|
||||
}, [getData, reqConfig.usersUrl]);
|
||||
|
||||
if (dataLoading) return <p><LoadingIcon /></p>;
|
||||
if (dataError) return <p>Error: {dataError.message}</p>;
|
||||
|
||||
const filteredUsers = users.filter((user) =>
|
||||
user.display_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.email?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const viewerIds = data
|
||||
.filter(user => user.status === 1)
|
||||
.map(user => user.user_id);
|
||||
|
||||
const handleAdd = async (user) => {
|
||||
try {
|
||||
await postData(reqConfig.metadataUrl, {
|
||||
user_id: user.user_id,
|
||||
role: 0,
|
||||
status: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (user) => {
|
||||
try {
|
||||
await postData(reqConfig.metadataUrl, {
|
||||
user_id: user.user_id,
|
||||
role: 0,
|
||||
status: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding user:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container my-5">
|
||||
<SearchToolbar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
<div className="mb-5 p-0 search-results">
|
||||
{searchTerm && (
|
||||
<>
|
||||
{filteredUsers.length > 0 ? (
|
||||
<div className="row g-3">
|
||||
{filteredUsers
|
||||
.filter(user => !viewerIds.includes(user.user_id))
|
||||
.map(user => (
|
||||
<UserCard key={user.user_id} user={user} renderMode="add" onAdd={() => handleAdd(user)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-white">No se encontraron resultados para "{searchTerm}"</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<h2>Usuarios añadidos</h2>
|
||||
<div className="rounded-4 p-3 user-container">
|
||||
<div className="row g-3 m-0">
|
||||
{data.filter(user => user.status === 1).map((user) => (
|
||||
<UserCard renderMode="delete" key={user.user_id} user={user} onDelete={() => handleDelete(user)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Usuarios;
|
||||
80
src/pages/Votar.jsx
Normal file
80
src/pages/Votar.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import LoadingIcon from "@/components/LoadingIcon";
|
||||
import MovieCard from "@/components/Movies/MovieCard";
|
||||
|
||||
import { DataProvider } from "@/context/DataContext";
|
||||
|
||||
import { useDataContext } from "@/hooks/useDataContext";
|
||||
import { useConfig } from "@/hooks/useConfig";
|
||||
import { Alert } from "react-bootstrap";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const Votar = () => {
|
||||
const { config, configLoading } = useConfig();
|
||||
|
||||
if (configLoading) return <p><LoadingIcon /></p>;
|
||||
|
||||
const reqConfig = {
|
||||
baseUrl: `${config.apiConfig.baseRawUrl}${config.apiConfig.endpoints.movies.getAll}`,
|
||||
params: {},
|
||||
};
|
||||
|
||||
return (
|
||||
<DataProvider config={reqConfig}>
|
||||
<VotarContent />
|
||||
</DataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const VotarContent = () => {
|
||||
const { data, loading, error } = useDataContext();
|
||||
const [alertShown, setAlertShown] = useState(() => localStorage.getItem('alertShown') === 'true');
|
||||
const [showAlert, setShowAlert] = useState(!alertShown);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAlert) return;
|
||||
|
||||
localStorage.setItem('alertShown', 'true');
|
||||
setAlertShown(true);
|
||||
|
||||
}, [showAlert]);
|
||||
|
||||
const handleCloseAlert = () => {
|
||||
setShowAlert(false);
|
||||
};
|
||||
|
||||
if (loading) return <p><LoadingIcon /></p>;
|
||||
if (error) return <p>Error: {error.message}</p>;
|
||||
|
||||
return (
|
||||
<main className="row m-0 p-0 justify-content-center">
|
||||
|
||||
{showAlert && (
|
||||
<Alert
|
||||
className="col-6 m-0 mt-3 text-center"
|
||||
variant="warning"
|
||||
role="alert"
|
||||
dismissible
|
||||
onClose={handleCloseAlert}
|
||||
>
|
||||
<strong>Tip: haz click en la portada de una película para ver su descripción</strong>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="row gap-3 mt-3 justify-content-center">
|
||||
{data?.map((movie) => (
|
||||
<MovieCard
|
||||
key={movie.movie_id}
|
||||
movie_id={movie.movie_id}
|
||||
title={movie.title}
|
||||
description={movie.description}
|
||||
cover={movie.cover}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default Votar;
|
||||
15
src/util/alertHelpers.jsx
Normal file
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);
|
||||
};
|
||||
16
src/util/constants.js
Normal file
16
src/util/constants.js
Normal file
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
const CONSTANTS = {
|
||||
// Roles
|
||||
ROLE_USER: 0,
|
||||
ROLE_ADMIN: 1,
|
||||
|
||||
// Estado de usuario
|
||||
STATUS_INACTIVE: 0,
|
||||
STATUS_ACTIVE: 1,
|
||||
|
||||
// Constantes
|
||||
MAX_CHARACTERS: 420,
|
||||
};
|
||||
|
||||
export { CONSTANTS };
|
||||
10
src/util/date.js
Normal file
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
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 '—';
|
||||
|
||||
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
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
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
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
29
vite.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "localhost",
|
||||
port: 3000,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1000,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
react: ['react', 'react-dom'],
|
||||
router: ['react-router-dom'],
|
||||
motion: ['framer-motion'],
|
||||
axios: ['axios'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user