[REPO REFACTOR]: changed to a better git repository structure with branches

This commit is contained in:
2025-11-01 04:00:11 +01:00
parent 02926320da
commit 1c8efeca66
83 changed files with 3563 additions and 0 deletions

33
eslint.config.js Normal file
View 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
View 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
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

46
package.json Normal file
View 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"
}
}

View 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"
}
}
}
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/fonts/OpenSans.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

40
src/App.jsx Normal file
View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
};

View 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};

View 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,
};

View 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>
);
};

View 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;
}

View 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
View 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
View 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
View 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);
}

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
h1.not-found {
font-size: 10em;
font-weight: bold;
}

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { AuthContext } from "../context/AuthContext";
export const useAuth = () => useContext(AuthContext);

View 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
View 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
View 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
};
};

View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { DataContext } from "../context/DataContext";
export const useDataContext = () => useContext(DataContext);

View 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: () => { }
};
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 }

View 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);
}
};

View 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";
}
};

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