diff --git a/eslint.config.js b/eslint.config.js
new file mode 100644
index 0000000..ec2b712
--- /dev/null
+++ b/eslint.config.js
@@ -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 },
+ ],
+ },
+ },
+]
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..dc7116a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Huertos de Cine
+
+
+
+
+
+
diff --git a/jsconfig.json b/jsconfig.json
new file mode 100644
index 0000000..30e99a0
--- /dev/null
+++ b/jsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src"]
+ }
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c8e96a6
--- /dev/null
+++ b/package.json
@@ -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"
+ }
+}
diff --git a/public/config/settings.dev.json b/public/config/settings.dev.json
new file mode 100644
index 0000000..8db1dd4
--- /dev/null
+++ b/public/config/settings.dev.json
@@ -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"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/public/config/settings.prod.json b/public/config/settings.prod.json
new file mode 100644
index 0000000..8db1dd4
--- /dev/null
+++ b/public/config/settings.prod.json
@@ -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"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..f280651
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/fonts/OpenSans.ttf b/public/fonts/OpenSans.ttf
new file mode 100644
index 0000000..ac587b4
Binary files /dev/null and b/public/fonts/OpenSans.ttf differ
diff --git a/public/fonts/ProductSansBold.ttf b/public/fonts/ProductSansBold.ttf
new file mode 100644
index 0000000..d847195
Binary files /dev/null and b/public/fonts/ProductSansBold.ttf differ
diff --git a/public/fonts/ProductSansBoldItalic.ttf b/public/fonts/ProductSansBoldItalic.ttf
new file mode 100644
index 0000000..129d12d
Binary files /dev/null and b/public/fonts/ProductSansBoldItalic.ttf differ
diff --git a/public/fonts/ProductSansItalic.ttf b/public/fonts/ProductSansItalic.ttf
new file mode 100644
index 0000000..5fc56d4
Binary files /dev/null and b/public/fonts/ProductSansItalic.ttf differ
diff --git a/public/fonts/ProductSansRegular.ttf b/public/fonts/ProductSansRegular.ttf
new file mode 100644
index 0000000..c0442ee
Binary files /dev/null and b/public/fonts/ProductSansRegular.ttf differ
diff --git a/src/App.jsx b/src/App.jsx
new file mode 100644
index 0000000..c23d80b
--- /dev/null
+++ b/src/App.jsx
@@ -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 (
+ <>
+
+
+ } />
+
+
+
+ } />
+ } />
+
+
+
+ } />
+ } />
+
+
+
+
+
+ >
+ )
+}
+
+export default App;
diff --git a/src/api/axiosInstance.js b/src/api/axiosInstance.js
new file mode 100644
index 0000000..5a4f265
--- /dev/null
+++ b/src/api/axiosInstance.js
@@ -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;
diff --git a/src/components/AnimatedDropdown.jsx b/src/components/AnimatedDropdown.jsx
new file mode 100644
index 0000000..fafbe1e
--- /dev/null
+++ b/src/components/AnimatedDropdown.jsx
@@ -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 }))
+ : (
+
+ );
+
+ const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
+
+ return (
+
+ {triggerElement}
+
+
+ {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}
+
+ )}
+
+
+ );
+};
+
+export default AnimatedDropdown;
diff --git a/src/components/AnimatedDropend.jsx b/src/components/AnimatedDropend.jsx
new file mode 100644
index 0000000..63e98e5
--- /dev/null
+++ b/src/components/AnimatedDropend.jsx
@@ -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
+ }))
+ : (
+
+ );
+
+ const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
+
+ return (
+
+ {triggerElement}
+
+
+ {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}
+
+ )}
+
+
+ );
+};
+
+export default AnimatedDropend;
diff --git a/src/components/Auth/IfAuthenticated.jsx b/src/components/Auth/IfAuthenticated.jsx
new file mode 100644
index 0000000..9504174
--- /dev/null
+++ b/src/components/Auth/IfAuthenticated.jsx
@@ -0,0 +1,8 @@
+import { useAuth } from "@/hooks/useAuth.js";
+
+const IfAuthenticated = ({ children }) => {
+ const { authStatus } = useAuth();
+ return authStatus === "authenticated" ? children : null;
+};
+
+export default IfAuthenticated;
diff --git a/src/components/Auth/IfNotAuthenticated.jsx b/src/components/Auth/IfNotAuthenticated.jsx
new file mode 100644
index 0000000..53593c1
--- /dev/null
+++ b/src/components/Auth/IfNotAuthenticated.jsx
@@ -0,0 +1,8 @@
+import { useAuth } from "@/hooks/useAuth.js";
+
+const IfNotAuthenticated = ({ children }) => {
+ const { authStatus } = useAuth();
+ return authStatus === "unauthenticated" ? children : null;
+};
+
+export default IfNotAuthenticated;
diff --git a/src/components/Auth/IfRole.jsx b/src/components/Auth/IfRole.jsx
new file mode 100644
index 0000000..336791a
--- /dev/null
+++ b/src/components/Auth/IfRole.jsx
@@ -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;
diff --git a/src/components/Auth/LoginForm.jsx b/src/components/Auth/LoginForm.jsx
new file mode 100644
index 0000000..38bc5db
--- /dev/null
+++ b/src/components/Auth/LoginForm.jsx
@@ -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 (
+
+ );
+};
+
+export default LoginForm;
diff --git a/src/components/Auth/PasswordInput.jsx b/src/components/Auth/PasswordInput.jsx
new file mode 100644
index 0000000..660f7ea
--- /dev/null
+++ b/src/components/Auth/PasswordInput.jsx
@@ -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 (
+
+
+
+ Contraseña
+
+
+
+
+
+
+
+
+ );
+};
+
+export default PasswordInput;
diff --git a/src/components/Auth/ProtectedRoute.jsx b/src/components/Auth/ProtectedRoute.jsx
new file mode 100644
index 0000000..911d951
--- /dev/null
+++ b/src/components/Auth/ProtectedRoute.jsx
@@ -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 ; // o un loader si quieres
+ if (authStatus === "unauthenticated") return ;
+ if (authStatus === "authenticated" && minimumRoles) {
+ const userRole = JSON.parse(localStorage.getItem("user"))?.role;
+ if (!minimumRoles.includes(userRole)) return ;
+ }
+ return children;
+};
+
+export default ProtectedRoute;
diff --git a/src/components/CardGrid.jsx b/src/components/CardGrid.jsx
new file mode 100644
index 0000000..81ad53b
--- /dev/null
+++ b/src/components/CardGrid.jsx
@@ -0,0 +1,20 @@
+import LoadingIcon from './LoadingIcon';
+
+const CardGrid = ({
+ items = [],
+ renderCard,
+ loaderRef,
+ loading = false
+}) => {
+ return (
+
+ {items.map((item, i) => renderCard(item, i))}
+
+
+ {loading && }
+
+
+ );
+};
+
+export default CardGrid;
diff --git a/src/components/CustomCarousel.jsx b/src/components/CustomCarousel.jsx
new file mode 100644
index 0000000..6b5257e
--- /dev/null
+++ b/src/components/CustomCarousel.jsx
@@ -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 (
+
+
+ {images.map((src, index) => (
+
+

+
+ ))}
+
+
+ );
+};
+
+export default CustomCarousel;
diff --git a/src/components/CustomModal.jsx b/src/components/CustomModal.jsx
new file mode 100644
index 0000000..a4d758f
--- /dev/null
+++ b/src/components/CustomModal.jsx
@@ -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 (
+
+
+ {title}
+
+
+
+ {children}
+
+
+ );
+}
+
+export default CustomModal;
\ No newline at end of file
diff --git a/src/components/File.jsx b/src/components/File.jsx
new file mode 100644
index 0000000..bb8bc2d
--- /dev/null
+++ b/src/components/File.jsx
@@ -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 (
+ window.open(`https://miarma.net/files/huertos/${file.file_name}`, "_blank")}
+ >
+
+
+ {file.file_name}}
+ >
+ {file.file_name}
+
+
+
+
+
+ );
+};
+
+export default File;
diff --git a/src/components/FileUpload.jsx b/src/components/FileUpload.jsx
new file mode 100644
index 0000000..44fd5e3
--- /dev/null
+++ b/src/components/FileUpload.jsx
@@ -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 (
+
+
+ 📎 Subir archivo
+
+ Arrastra o haz click para seleccionar archivos (Máx. 10MB)
+
+
+ {selectedFiles.length > 0 && (
+
+ {selectedFiles.map((file, idx) => (
+ -
+ 📄 {file.name}
+ {
+ e.stopPropagation();
+ removeFile(idx);
+ }}
+ />
+
+ ))}
+
+ )}
+
+
+ );
+});
+
+export default FileUpload;
diff --git a/src/components/FloatingMenu/AddMovieButton.jsx b/src/components/FloatingMenu/AddMovieButton.jsx
new file mode 100644
index 0000000..5e41020
--- /dev/null
+++ b/src/components/FloatingMenu/AddMovieButton.jsx
@@ -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 (
+
+ );
+}
+
+export default AddMovieButton;
\ No newline at end of file
diff --git a/src/components/FloatingMenu/AddUserButton.jsx b/src/components/FloatingMenu/AddUserButton.jsx
new file mode 100644
index 0000000..65dcd63
--- /dev/null
+++ b/src/components/FloatingMenu/AddUserButton.jsx
@@ -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 (
+
+ );
+}
+
+export default AddUserButton;
\ No newline at end of file
diff --git a/src/components/FloatingMenu/FloatingMenu.jsx b/src/components/FloatingMenu/FloatingMenu.jsx
new file mode 100644
index 0000000..58a635f
--- /dev/null
+++ b/src/components/FloatingMenu/FloatingMenu.jsx
@@ -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
;
+
+ 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: ,
+ key: "add-movie",
+ onClick: () => setMovieModal(true)
+ });
+ }
+
+ if (location.pathname.includes("/usuarios")) {
+ buttons.push({
+ component: ,
+ 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 (
+ <>
+
+
+ {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}
+
+ ))}
+
+ )}
+
+
+
setMovieModal(false)} onSubmit={handleMovieSubmit} />
+ setUserModal(false)} onSubmit={handleUserSubmit} />
+
+
+
+ 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;
\ No newline at end of file
diff --git a/src/components/FloatingMenu/ThemeButton.jsx b/src/components/FloatingMenu/ThemeButton.jsx
new file mode 100644
index 0000000..faa2ba4
--- /dev/null
+++ b/src/components/FloatingMenu/ThemeButton.jsx
@@ -0,0 +1,14 @@
+import { useTheme } from "@/hooks/useTheme";
+import "@/css/ThemeButton.css";
+
+const ThemeButton = () => {
+ const { theme, toggleTheme } = useTheme();
+
+ return (
+
+ );
+}
+
+export default ThemeButton;
\ No newline at end of file
diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx
new file mode 100644
index 0000000..b2e6f9f
--- /dev/null
+++ b/src/components/Footer.jsx
@@ -0,0 +1,13 @@
+const Footer = () => {
+ return (
+
+ );
+}
+
+export default Footer;
\ No newline at end of file
diff --git a/src/components/Header.jsx b/src/components/Header.jsx
new file mode 100644
index 0000000..8428f54
--- /dev/null
+++ b/src/components/Header.jsx
@@ -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 (
+ <>
+
+
+
+
+ Cerrar sesión
+
+ }
+ >
+ {`@${JSON.parse(localStorage.getItem("user"))?.user_name}`}
+
+
+
+ votos
+
+
+
+
+
+
+ usuarios
+
+
+
+
+
+
+ >
+ );
+}
+
+export default Header;
\ No newline at end of file
diff --git a/src/components/LoadingIcon.jsx b/src/components/LoadingIcon.jsx
new file mode 100644
index 0000000..e877e43
--- /dev/null
+++ b/src/components/LoadingIcon.jsx
@@ -0,0 +1,10 @@
+import { faSpinner } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+
+const LoadingIcon = () => {
+ return (
+
+ );
+}
+
+export default LoadingIcon;
\ No newline at end of file
diff --git a/src/components/Movies/AddMovieModal.jsx b/src/components/Movies/AddMovieModal.jsx
new file mode 100644
index 0000000..069f160
--- /dev/null
+++ b/src/components/Movies/AddMovieModal.jsx
@@ -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 (
+
+
+
+
+
+ Título
+
+ setTitle(e.target.value)}
+ className="themed-input rounded-4"
+ />
+
+
+
+
+
+ Descripción
+
+ setDescription(e.target.value)}
+ className="themed-input rounded-4"
+ />
+
+
+
+
+
+ Portada
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AddMovieModal;
diff --git a/src/components/Movies/MovieCard.jsx b/src/components/Movies/MovieCard.jsx
new file mode 100644
index 0000000..7cfb2b2
--- /dev/null
+++ b/src/components/Movies/MovieCard.jsx
@@ -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 (
+ <>
+
+
+
+
+
+
+
+

setModal(true)}
+ className="rounded-top-4"
+ />
+
+
+
+ { e.stopPropagation(); handleVoteClick('up'); }}
+ className={`vote-button ${userVote === 'up' ? 'active' : ''}`}
+ >
+
+
+ {votes || 0}
+ { e.stopPropagation(); handleVoteClick('down'); }}
+ className={`vote-button ${userVote === 'down' ? 'active' : ''}`}
+ >
+
+
+
+
+
+
+
+ setModal(false)} title={title}>
+
+
+
+ setDeleteTarget(null)}
+ >
+ ¿Estás seguro de que quieres eliminar la película?
+
+
+
+
+
+
+ setEditModal(false)} title="Editar película">
+ {
+ handleEdit(formData);
+ setEditModal(false);
+ }}
+ onCancel={() => setEditModal(false)}
+ />
+
+ >
+ );
+};
+
+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 (
+
+
+
+
+ Título
+
+ setTitle(e.target.value)}
+ className="themed-input rounded-4"
+ />
+
+
+
+
+
+ Descripción
+
+ setDescription(e.target.value)}
+ className="themed-input rounded-4"
+ />
+
+
+
+
+
+ Nueva portada (opcional)
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+
+export default MovieCard;
diff --git a/src/components/Movies/MovieCardMobile.jsx b/src/components/Movies/MovieCardMobile.jsx
new file mode 100644
index 0000000..8c40d54
--- /dev/null
+++ b/src/components/Movies/MovieCardMobile.jsx
@@ -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 (
+
+
+

+
+
+
+
+
+
+
+
+
{title}
+
+ {
+ expanded
+ ? description
+ : (description.length > 400 ? `${description.slice(0, 400)}...` : description)
+ }
+
+ {
+ description.length > 400 && (
+
+ )
+ }
+
+
+
+ );
+};
+
+MovieCardMobile.propTypes = {
+ title: PropTypes.string.isRequired,
+ description: PropTypes.string.isRequired,
+ cover: PropTypes.string.isRequired,
+};
+
+export default MovieCardMobile;
\ No newline at end of file
diff --git a/src/components/NavBar.jsx b/src/components/NavBar.jsx
new file mode 100644
index 0000000..f18e74c
--- /dev/null
+++ b/src/components/NavBar.jsx
@@ -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 (
+
+ );
+};
+
+NavBar.propTypes = {
+ children: PropTypes.node.isRequired,
+ rightContent: PropTypes.node,
+};
+
+export default NavBar;
diff --git a/src/components/NotificationModal.jsx b/src/components/NotificationModal.jsx
new file mode 100644
index 0000000..aef82c9
--- /dev/null
+++ b/src/components/NotificationModal.jsx
@@ -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 (
+
+
+
+
+ {title}
+
+
+
+
+ {message}
+
+
+
+ {buttons.map((btn, index) => (
+
+ ))}
+
+
+ );
+};
+
+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;
diff --git a/src/components/SearchToolbar.jsx b/src/components/SearchToolbar.jsx
new file mode 100644
index 0000000..126fa82
--- /dev/null
+++ b/src/components/SearchToolbar.jsx
@@ -0,0 +1,15 @@
+const SearchToolbar = ({ searchTerm, onSearchChange }) => (
+
+
+ onSearchChange(e.target.value)}
+ />
+
+
+);
+
+export default SearchToolbar;
\ No newline at end of file
diff --git a/src/components/Users/AddUserModal.jsx b/src/components/Users/AddUserModal.jsx
new file mode 100644
index 0000000..37bab8c
--- /dev/null
+++ b/src/components/Users/AddUserModal.jsx
@@ -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 (
+
+
+
+
+
+ Nombre para mostrar
+
+ {e.target.value = e.target.value.toUpperCase(); handleChange(e);}}
+ className="themed-input rounded-4"
+ />
+
+
+ {/* Password input con toggle show/hide */}
+
+
+
+ Contraseña
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AddViewerModal;
diff --git a/src/components/Users/UserCard.jsx b/src/components/Users/UserCard.jsx
new file mode 100644
index 0000000..7fb5685
--- /dev/null
+++ b/src/components/Users/UserCard.jsx
@@ -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 (
+
+
+
+
{user.display_name}
+
+ {renderMode === 'add' ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+export default UserCard;
\ No newline at end of file
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx
new file mode 100644
index 0000000..3336d0e
--- /dev/null
+++ b/src/context/AuthContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
diff --git a/src/context/ConfigContext.jsx b/src/context/ConfigContext.jsx
new file mode 100644
index 0000000..25b59bc
--- /dev/null
+++ b/src/context/ConfigContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
+
+ConfigProvider.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+export {ConfigContext};
\ No newline at end of file
diff --git a/src/context/DataContext.jsx b/src/context/DataContext.jsx
new file mode 100644
index 0000000..a99a74e
--- /dev/null
+++ b/src/context/DataContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
+
+DataProvider.propTypes = {
+ config: PropTypes.shape({
+ baseUrl: PropTypes.string.isRequired,
+ params: PropTypes.object,
+ }).isRequired,
+ children: PropTypes.node.isRequired,
+};
\ No newline at end of file
diff --git a/src/context/ThemeContext.jsx b/src/context/ThemeContext.jsx
new file mode 100644
index 0000000..043a201
--- /dev/null
+++ b/src/context/ThemeContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+};
diff --git a/src/css/AnimatedDropdown.css b/src/css/AnimatedDropdown.css
new file mode 100644
index 0000000..ba10c6e
--- /dev/null
+++ b/src/css/AnimatedDropdown.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/css/CustomCarousel.css b/src/css/CustomCarousel.css
new file mode 100644
index 0000000..4e44fca
--- /dev/null
+++ b/src/css/CustomCarousel.css
@@ -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;
+}
diff --git a/src/css/File.css b/src/css/File.css
new file mode 100644
index 0000000..239931e
--- /dev/null
+++ b/src/css/File.css
@@ -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);
+ }
+
\ No newline at end of file
diff --git a/src/css/FileUpload.css b/src/css/FileUpload.css
new file mode 100644
index 0000000..1cb40c4
--- /dev/null
+++ b/src/css/FileUpload.css
@@ -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);
+}
diff --git a/src/css/FloatingMenu.css b/src/css/FloatingMenu.css
new file mode 100644
index 0000000..40c0267
--- /dev/null
+++ b/src/css/FloatingMenu.css
@@ -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);
+}
diff --git a/src/css/FloatingMenuButton.css b/src/css/FloatingMenuButton.css
new file mode 100644
index 0000000..d3bc7b9
--- /dev/null
+++ b/src/css/FloatingMenuButton.css
@@ -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);
+}
diff --git a/src/css/Header.css b/src/css/Header.css
new file mode 100644
index 0000000..e61d05e
--- /dev/null
+++ b/src/css/Header.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/css/LoginForm.css b/src/css/LoginForm.css
new file mode 100644
index 0000000..7f589e1
--- /dev/null
+++ b/src/css/LoginForm.css
@@ -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);
+}
diff --git a/src/css/MovieCard.css b/src/css/MovieCard.css
new file mode 100644
index 0000000..ac2af93
--- /dev/null
+++ b/src/css/MovieCard.css
@@ -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;
+}
\ No newline at end of file
diff --git a/src/css/Navbar.css b/src/css/Navbar.css
new file mode 100644
index 0000000..195f523
--- /dev/null
+++ b/src/css/Navbar.css
@@ -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);
+}
\ No newline at end of file
diff --git a/src/css/NotFound.css b/src/css/NotFound.css
new file mode 100644
index 0000000..6903c71
--- /dev/null
+++ b/src/css/NotFound.css
@@ -0,0 +1,4 @@
+h1.not-found {
+ font-size: 10em;
+ font-weight: bold;
+}
\ No newline at end of file
diff --git a/src/css/PasswordInput.css b/src/css/PasswordInput.css
new file mode 100644
index 0000000..5fd7f7f
--- /dev/null
+++ b/src/css/PasswordInput.css
@@ -0,0 +1,8 @@
+.show-button svg {
+ color: var(--text-color);
+
+}
+
+.show-button:hover svg {
+ color: var(--hover-color);
+}
diff --git a/src/css/ThemeButton.css b/src/css/ThemeButton.css
new file mode 100644
index 0000000..805a93f
--- /dev/null
+++ b/src/css/ThemeButton.css
@@ -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);
+}
diff --git a/src/css/UserCard.css b/src/css/UserCard.css
new file mode 100644
index 0000000..bdd5748
--- /dev/null
+++ b/src/css/UserCard.css
@@ -0,0 +1,5 @@
+.user-card {
+ background-color: var(--cocoa-brown) !important;
+ border: none !important;
+ color: var(--selective-yellow) !important;
+}
\ No newline at end of file
diff --git a/src/css/Usuarios.css b/src/css/Usuarios.css
new file mode 100644
index 0000000..34b50d0
--- /dev/null
+++ b/src/css/Usuarios.css
@@ -0,0 +1,4 @@
+.user-container {
+ border: 2px solid var(--selective-yellow) !important;
+ background-color: var(--cocoa-brown-light-2);
+}
diff --git a/src/css/index.css b/src/css/index.css
new file mode 100644
index 0000000..7fe1bd4
--- /dev/null
+++ b/src/css/index.css
@@ -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));
+ }
+}
\ No newline at end of file
diff --git a/src/hooks/useAuth.js b/src/hooks/useAuth.js
new file mode 100644
index 0000000..a6e5c3a
--- /dev/null
+++ b/src/hooks/useAuth.js
@@ -0,0 +1,4 @@
+import { useContext } from "react";
+import { AuthContext } from "../context/AuthContext";
+
+export const useAuth = () => useContext(AuthContext);
diff --git a/src/hooks/useBreakpoint.js b/src/hooks/useBreakpoint.js
new file mode 100644
index 0000000..b1d7a97
--- /dev/null
+++ b/src/hooks/useBreakpoint.js
@@ -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;
+}
diff --git a/src/hooks/useConfig.js b/src/hooks/useConfig.js
new file mode 100644
index 0000000..a895e6b
--- /dev/null
+++ b/src/hooks/useConfig.js
@@ -0,0 +1,4 @@
+import { useContext } from "react";
+import { ConfigContext } from "../context/ConfigContext.jsx";
+
+export const useConfig = () => useContext(ConfigContext);
diff --git a/src/hooks/useData.js b/src/hooks/useData.js
new file mode 100644
index 0000000..a21e2af
--- /dev/null
+++ b/src/hooks/useData.js
@@ -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
+ };
+};
diff --git a/src/hooks/useDataContext.js b/src/hooks/useDataContext.js
new file mode 100644
index 0000000..f4b6a80
--- /dev/null
+++ b/src/hooks/useDataContext.js
@@ -0,0 +1,4 @@
+import { useContext } from "react";
+import { DataContext } from "../context/DataContext";
+
+export const useDataContext = () => useContext(DataContext);
diff --git a/src/hooks/usePaginatedList.js b/src/hooks/usePaginatedList.js
new file mode 100644
index 0000000..f1efd36
--- /dev/null
+++ b/src/hooks/usePaginatedList.js
@@ -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: () => { }
+ };
+};
diff --git a/src/hooks/useSessionRenewal.jsx b/src/hooks/useSessionRenewal.jsx
new file mode 100644
index 0000000..4566619
--- /dev/null
+++ b/src/hooks/useSessionRenewal.jsx
@@ -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 && (
+ {
+ 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;
diff --git a/src/hooks/useTheme.js b/src/hooks/useTheme.js
new file mode 100644
index 0000000..026c44b
--- /dev/null
+++ b/src/hooks/useTheme.js
@@ -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 ");
+ }
+ return context;
+};
diff --git a/src/main.jsx b/src/main.jsx
new file mode 100644
index 0000000..3d11e9c
--- /dev/null
+++ b/src/main.jsx
@@ -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(
+
+
+
+
+
+
+
+
+
+
+ ,
+)
diff --git a/src/pages/Login.jsx b/src/pages/Login.jsx
new file mode 100644
index 0000000..1b7e04c
--- /dev/null
+++ b/src/pages/Login.jsx
@@ -0,0 +1,11 @@
+import LoginForm from "@/components/Auth/LoginForm";
+
+const Login = () => {
+ return (
+
+
+
+ );
+}
+
+export default Login;
\ No newline at end of file
diff --git a/src/pages/NotFound.jsx b/src/pages/NotFound.jsx
new file mode 100644
index 0000000..687e14f
--- /dev/null
+++ b/src/pages/NotFound.jsx
@@ -0,0 +1,16 @@
+import '@/css/NotFound.css';
+import { Link } from "react-router-dom";
+
+const NotFound = () => {
+ return (
+
+ 404
+ Página no encontrada
+
+ Volver al inicio
+
+
+ );
+}
+
+export default NotFound;
\ No newline at end of file
diff --git a/src/pages/Usuarios.jsx b/src/pages/Usuarios.jsx
new file mode 100644
index 0000000..e13d5cb
--- /dev/null
+++ b/src/pages/Usuarios.jsx
@@ -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
;
+
+ 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 (
+
+
+
+ );
+}
+
+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
;
+ if (dataError) return Error: {dataError.message}
;
+
+ 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 (
+
+
+
+ {searchTerm && (
+ <>
+ {filteredUsers.length > 0 ? (
+
+ {filteredUsers
+ .filter(user => !viewerIds.includes(user.user_id))
+ .map(user => (
+ handleAdd(user)} />
+ ))}
+
+ ) : (
+
No se encontraron resultados para "{searchTerm}"
+ )}
+ >
+ )}
+
+ <>
+ Usuarios añadidos
+
+
+ {data.filter(user => user.status === 1).map((user) => (
+ handleDelete(user)} />
+ ))}
+
+
+ >
+
+ );
+}
+
+export default Usuarios;
\ No newline at end of file
diff --git a/src/pages/Votar.jsx b/src/pages/Votar.jsx
new file mode 100644
index 0000000..8276f20
--- /dev/null
+++ b/src/pages/Votar.jsx
@@ -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
;
+
+ const reqConfig = {
+ baseUrl: `${config.apiConfig.baseRawUrl}${config.apiConfig.endpoints.movies.getAll}`,
+ params: {},
+ };
+
+ return (
+
+
+
+ );
+}
+
+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
;
+ if (error) return Error: {error.message}
;
+
+ return (
+
+
+ {showAlert && (
+
+ Tip: haz click en la portada de una película para ver su descripción
+
+ )}
+
+
+ {data?.map((movie) => (
+
+ ))}
+
+
+ );
+}
+
+
+export default Votar;
\ No newline at end of file
diff --git a/src/util/alertHelpers.jsx b/src/util/alertHelpers.jsx
new file mode 100644
index 0000000..17124b8
--- /dev/null
+++ b/src/util/alertHelpers.jsx
@@ -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 (
+
+ {typeof error === 'string' ? error : 'An unexpected error occurred.'}
+
+ );
+};
+
+export const resetErrorIfEditEnds = (editMode, setError) => {
+ if (!editMode) setError(null);
+};
diff --git a/src/util/constants.js b/src/util/constants.js
new file mode 100644
index 0000000..4ddf7ab
--- /dev/null
+++ b/src/util/constants.js
@@ -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 };
diff --git a/src/util/date.js b/src/util/date.js
new file mode 100644
index 0000000..c9d9dc3
--- /dev/null
+++ b/src/util/date.js
@@ -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 }
\ No newline at end of file
diff --git a/src/util/parsers/dateParser.js b/src/util/parsers/dateParser.js
new file mode 100644
index 0000000..cb42fe0
--- /dev/null
+++ b/src/util/parsers/dateParser.js
@@ -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);
+ }
+};
diff --git a/src/util/parsers/errorParser.js b/src/util/parsers/errorParser.js
new file mode 100644
index 0000000..971bcd8
--- /dev/null
+++ b/src/util/parsers/errorParser.js
@@ -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";
+ }
+};
diff --git a/src/util/passwordGenerator.js b/src/util/passwordGenerator.js
new file mode 100644
index 0000000..9a99610
--- /dev/null
+++ b/src/util/passwordGenerator.js
@@ -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('');
+};
diff --git a/src/util/tokenUtils.js b/src/util/tokenUtils.js
new file mode 100644
index 0000000..38e5970
--- /dev/null
+++ b/src/util/tokenUtils.js
@@ -0,0 +1,7 @@
+export const parseJwt = (token) => {
+ try {
+ return JSON.parse(atob(token.split('.')[1]));
+ } catch (e) {
+ return null;
+ }
+};
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..3fc9085
--- /dev/null
+++ b/vite.config.js
@@ -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'],
+ }
+ }
+ }
+ }
+})