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