Compare commits
3 Commits
fcd477e876
...
3a848b3f56
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a848b3f56 | ||
|
|
d9eb92300a | ||
|
|
bf40b235f0 |
@@ -1,92 +0,0 @@
|
||||
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;
|
||||
@@ -1,122 +0,0 @@
|
||||
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;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { useAuth } from "@/hooks/useAuth.js";
|
||||
|
||||
const IfAuthenticated = ({ children }) => {
|
||||
const { authStatus } = useAuth();
|
||||
return authStatus === "authenticated" ? children : null;
|
||||
};
|
||||
|
||||
export default IfAuthenticated;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { useAuth } from "@/hooks/useAuth.js";
|
||||
|
||||
const IfNotAuthenticated = ({ children }) => {
|
||||
const { authStatus } = useAuth();
|
||||
return authStatus === "unauthenticated" ? children : null;
|
||||
};
|
||||
|
||||
export default IfNotAuthenticated;
|
||||
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
@@ -1,120 +0,0 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUser } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Form, Button, Alert, FloatingLabel, Row, Col } from 'react-bootstrap';
|
||||
import PasswordInput from '@/components/Auth/PasswordInput.jsx';
|
||||
|
||||
import { useContext, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { AuthContext } from "@/context/AuthContext.jsx";
|
||||
|
||||
import CustomContainer from '@/components/CustomContainer.jsx';
|
||||
import ContentWrapper from '@/components/ContentWrapper.jsx';
|
||||
|
||||
import '@/css/LoginForm.css';
|
||||
|
||||
const LoginForm = () => {
|
||||
const { login, error } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [formState, setFormState] = useState({
|
||||
emailOrUserName: "",
|
||||
password: "",
|
||||
keepLoggedIn: false
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormState((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formState.emailOrUserName);
|
||||
|
||||
const loginBody = {
|
||||
password: formState.password,
|
||||
keepLoggedIn: Boolean(formState.keepLoggedIn),
|
||||
};
|
||||
|
||||
if (isEmail) {
|
||||
loginBody.email = formState.emailOrUserName;
|
||||
} else {
|
||||
loginBody.userName = formState.emailOrUserName;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(loginBody);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
console.error("Error de login:", err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomContainer>
|
||||
<ContentWrapper>
|
||||
<div className="login-card card shadow p-5 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">Inicio de sesión</h1>
|
||||
<Form className="d-flex flex-column gap-5" onSubmit={handleSubmit}>
|
||||
<div className="d-flex flex-column gap-3">
|
||||
<FloatingLabel
|
||||
controlId="floatingUsuario"
|
||||
label={
|
||||
<>
|
||||
<FontAwesomeIcon icon={faUser} className="me-2" />
|
||||
Usuario o Email
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder=""
|
||||
name="emailOrUserName"
|
||||
value={formState.emailOrUserName}
|
||||
onChange={handleChange}
|
||||
className="rounded-4"
|
||||
/>
|
||||
</FloatingLabel>
|
||||
|
||||
<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) => { formState.keepLoggedIn = e.target.checked; setFormState({ ...formState }) }}
|
||||
/>
|
||||
{/*<Link disabled to="#" className="muted">
|
||||
Olvidé mi contraseña
|
||||
</Link>*/}
|
||||
</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>
|
||||
</ContentWrapper>
|
||||
</CustomContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default LoginForm;
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
@@ -1,15 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const ContentWrapper = ({ children }) => {
|
||||
return (
|
||||
<div className="container-xl">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ContentWrapper.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default ContentWrapper;
|
||||
@@ -1,47 +0,0 @@
|
||||
import Slider from 'react-slick';
|
||||
import '@/css/CustomCarousel.css';
|
||||
|
||||
const CustomCarousel = ({ images }) => {
|
||||
const settings = {
|
||||
dots: false,
|
||||
infinite: true,
|
||||
speed: 500,
|
||||
slidesToShow: 2,
|
||||
slidesToScroll: 1,
|
||||
arrows: false,
|
||||
autoplay: true,
|
||||
autoplaySpeed: 3000,
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 768, // móviles
|
||||
settings: {
|
||||
slidesToShow: 1,
|
||||
arrows: false,
|
||||
autoplay: true,
|
||||
autoplaySpeed: 3000,
|
||||
dots: false,
|
||||
infinite: true,
|
||||
speed: 500
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-4">
|
||||
<Slider {...settings}>
|
||||
{images.map((src, index) => (
|
||||
<div key={index} className='carousel-img-wrapper'>
|
||||
<img
|
||||
src={src}
|
||||
alt={`slide-${index}`}
|
||||
className="carousel-img"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Slider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomCarousel;
|
||||
@@ -1,15 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const CustomContainer = ({ children }) => {
|
||||
return (
|
||||
<main className="px-4 py-5">
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
CustomContainer.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
}
|
||||
|
||||
export default CustomContainer;
|
||||
@@ -1,26 +0,0 @@
|
||||
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="xl" centered>
|
||||
<Modal.Header className='justify-content-between rounded-top-4'>
|
||||
<Modal.Title>{title}</Modal.Title>
|
||||
<Button variant='transparent' onClick={onClose}>
|
||||
<FontAwesomeIcon icon={faXmark} className='close-button fa-xl' />
|
||||
</Button>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="rounded-bottom-4 p-0"
|
||||
style={{
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto',
|
||||
padding: '1rem',
|
||||
}}>
|
||||
{children}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomModal;
|
||||
@@ -1,56 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
|
||||
import '@/css/Footer.css';
|
||||
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
||||
|
||||
const Footer = () => {
|
||||
const [heart, setHeart] = useState('💜');
|
||||
|
||||
useEffect(() => {
|
||||
const hearts = ["❤️", "💛", "🧡", "💚", "💙", "💜"];
|
||||
const randomHeart = () => hearts[Math.floor(Math.random() * hearts.length)];
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setHeart(randomHeart());
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<footer className="footer d-flex flex-column align-items-center gap-5 pt-5 px-4">
|
||||
<div className="footer-columns w-100" style={{ maxWidth: '900px' }}>
|
||||
<div className="footer-column">
|
||||
<h4 className="footer-title">Contacto</h4>
|
||||
<div className="contact-info p-4">
|
||||
<a
|
||||
href="https://github.com/Gallardo7761"
|
||||
target="_blank"
|
||||
className='text-break d-block'
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FontAwesomeIcon icon={faGithub} className="fa-icon me-2 " />
|
||||
Gallardo7761
|
||||
</a>
|
||||
<a href="mailto:jose@miarma.net" className="text-break d-block">
|
||||
<FontAwesomeIcon icon={faEnvelope} className="fa-icon me-2" />
|
||||
jose@miarma.net
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="footer-bottom w-100 py-5 text-center">
|
||||
<h6 id="devd" className='m-0'>
|
||||
Hecho con <span className="heart-anim">{heart}</span> por{' '}
|
||||
<a href="https://gallardo.dev" target="_blank" rel="noopener noreferrer">
|
||||
Gallardo7761
|
||||
</a>
|
||||
</h6>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -1,19 +0,0 @@
|
||||
import '@/css/Header.css';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const Header = () => {
|
||||
|
||||
return (
|
||||
<header className={`text-center bg-img`}>
|
||||
<div className="m-0 p-5 mask">
|
||||
<div className="d-flex flex-column justify-content-center align-items-center h-100">
|
||||
<Link to='/' className='text-decoration-none'>
|
||||
<h1 className='header-title m-0 text-white shadowed'>Tu página web</h1>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
@@ -34,13 +34,13 @@ const CodeEditor = ({ className = "", syntax, readOnly, onChange, value, editorE
|
||||
onChange={onChange}
|
||||
onMount={onMount}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
minimap: { enabled: true },
|
||||
automaticLayout: true,
|
||||
fontFamily: 'Fira Code',
|
||||
fontLigatures: true,
|
||||
fontSize: 18,
|
||||
lineHeight: 1.5,
|
||||
scrollbar: { verticalScrollbarSize: 0 },
|
||||
scrollbar: { verticalScrollbarSize: 10 },
|
||||
wordWrap: "on",
|
||||
formatOnPaste: true,
|
||||
readOnly: readOnly || false,
|
||||
|
||||
@@ -3,8 +3,7 @@ import { Modal, Button, Form } from 'react-bootstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useState } from 'react';
|
||||
import PasswordInput from '@/components/Auth/PasswordInput';
|
||||
import { renderErrorAlert } from '@/util/alertHelpers';
|
||||
import PasswordInput from '@/components/Pastes/PasswordInput';
|
||||
import '@/css/PasswordModal.css';
|
||||
|
||||
const PasswordModal = ({
|
||||
@@ -37,8 +36,6 @@ const PasswordModal = ({
|
||||
Esta paste está protegida con contraseña. Introduce la clave para continuar.
|
||||
</p>
|
||||
|
||||
{renderErrorAlert(error)}
|
||||
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -1,21 +1,33 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Form, Button, Row, Col, FloatingLabel, Alert } from "react-bootstrap";
|
||||
import { Form, Button, Row, Col, FloatingLabel } from "react-bootstrap";
|
||||
import '@/css/PastePanel.css';
|
||||
import PasswordInput from "@/components/Auth/PasswordInput";
|
||||
import PasswordInput from "@/components/Pastes/PasswordInput";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCircle, faCode, faHeader } from "@fortawesome/free-solid-svg-icons";
|
||||
import CodeEditor from "./CodeEditor";
|
||||
import PublicPasteItem from "./PublicPasteItem";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useDataContext } from "@/hooks/useDataContext";
|
||||
import PasswordModal from "@/components/Auth/PasswordModal.jsx";
|
||||
import { useError } from '@/context/ErrorContext';
|
||||
import PasswordModal from "@/components/Pastes/PasswordModal.jsx";
|
||||
import { Client } from "@stomp/stompjs";
|
||||
import SockJS from 'sockjs-client';
|
||||
|
||||
const INITIAL_FORM_DATA = {
|
||||
title: "",
|
||||
content: "",
|
||||
syntax: "",
|
||||
burnAfter: false,
|
||||
isPrivate: false,
|
||||
isRt: false,
|
||||
password: ""
|
||||
};
|
||||
|
||||
const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnectChange }) => {
|
||||
const { pasteKey: urlPasteKey, rtKey } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { getData } = useDataContext();
|
||||
const { showError } = useError();
|
||||
|
||||
const activeKey = propKey || urlPasteKey || rtKey;
|
||||
|
||||
@@ -26,32 +38,35 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
|
||||
const [stompClient, setStompClient] = useState(null);
|
||||
const [connected, setConnected] = useState(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
title: "",
|
||||
content: "",
|
||||
syntax: "",
|
||||
burnAfter: false,
|
||||
isPrivate: false,
|
||||
isRt: false,
|
||||
password: ""
|
||||
});
|
||||
const [formData, setFormData] = useState({ ...INITIAL_FORM_DATA });
|
||||
|
||||
const lastSavedContent = useRef(formData.content);
|
||||
|
||||
const isReadOnly = !!selectedPaste || mode === 'rt';
|
||||
const isStaticView = mode === 'static' && !!activeKey;
|
||||
const isReadOnly = isStaticView || mode === 'rt';
|
||||
const isEditorReadOnly = isStaticView;
|
||||
const titleValue = mode === 'rt'
|
||||
? `Sesión: ${activeKey}`
|
||||
: (selectedPaste?.title ?? formData.title ?? "");
|
||||
const isRemoteChange = useRef(false);
|
||||
|
||||
// Sincroniza el panel cuando cambia el modo o la clave activa:
|
||||
// - modo static: intenta cargar la paste seleccionada
|
||||
// - modo create: reinicia todo el formulario y errores
|
||||
useEffect(() => {
|
||||
if (mode === 'static' && activeKey) {
|
||||
fetchPaste(activeKey);
|
||||
} else if (mode === 'create') {
|
||||
setSelectedPaste(null);
|
||||
setFormData({ title: "", content: "", syntax: "", burnAfter: false, isPrivate: false, password: "" });
|
||||
setFormData({ ...INITIAL_FORM_DATA });
|
||||
setFieldErrors({});
|
||||
setEditorErrors([]);
|
||||
}
|
||||
}, [activeKey, mode]);
|
||||
|
||||
// Gestiona el ciclo de vida del WebSocket en tiempo real:
|
||||
// conecta al entrar en modo rt y limpia la conexión al salir.
|
||||
// Los cambios remotos marcan `isRemoteChange` para no disparar autosave en bucle.
|
||||
useEffect(() => {
|
||||
if (mode === 'rt' && activeKey) {
|
||||
const socketUrl = import.meta.env.MODE === 'production'
|
||||
@@ -98,6 +113,8 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
|
||||
}
|
||||
}, [mode, activeKey]);
|
||||
|
||||
// Autosave con debounce en sesiones RT:
|
||||
// solo guarda cuando el contenido local cambia y evita guardar cambios que vienen del socket.
|
||||
useEffect(() => {
|
||||
if (mode === 'rt' && connected && formData.content) {
|
||||
|
||||
@@ -132,7 +149,10 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
|
||||
}
|
||||
}, [formData.content, mode, connected, activeKey]);
|
||||
|
||||
// Actualiza estado local y, si hay sesión RT activa, propaga el cambio al resto de clientes.
|
||||
const handleChange = (key, value) => {
|
||||
if (isReadOnly && mode !== 'rt') return;
|
||||
|
||||
const updatedData = { ...formData, [key]: value, isRt: mode === 'rt' };
|
||||
|
||||
setFormData(updatedData);
|
||||
@@ -150,8 +170,16 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
|
||||
setFieldErrors({});
|
||||
setEditorErrors([]);
|
||||
|
||||
const normalizedTitle = (formData.title ?? "").trim();
|
||||
const payload = {
|
||||
...formData,
|
||||
title: formData.isPrivate
|
||||
? (normalizedTitle || "Sin título")
|
||||
: formData.title
|
||||
};
|
||||
|
||||
try {
|
||||
if (onSubmit) await onSubmit(formData);
|
||||
if (onSubmit) await onSubmit(payload);
|
||||
} catch (error) {
|
||||
if (error.status === 422 && error.errors) {
|
||||
const newFieldErrors = {};
|
||||
@@ -171,6 +199,10 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
|
||||
|
||||
const handleSelectPaste = (key) => navigate(`/s/${key}`);
|
||||
|
||||
// Lookup de paste estática:
|
||||
// - 403: pide contraseña
|
||||
// - 404: redirige al inicio
|
||||
// Se hace en modo silencioso para que no abra el modal global en errores esperados.
|
||||
const fetchPaste = async (key, pwd = "") => {
|
||||
const url = import.meta.env.MODE === 'production'
|
||||
? `https://api.miarma.net/v2/mpaste/pastes/s/${key}`
|
||||
@@ -179,18 +211,23 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
|
||||
const headers = pwd ? { "X-Paste-Password": pwd } : {};
|
||||
|
||||
try {
|
||||
const response = await getData(url, null, false, headers, true);
|
||||
const response = await getData(url, {
|
||||
params: null,
|
||||
refresh: false,
|
||||
headers,
|
||||
silent: true,
|
||||
});
|
||||
|
||||
if (response) {
|
||||
setSelectedPaste(response);
|
||||
setShowPasswordModal(false);
|
||||
setFormData({
|
||||
title: response.title ?? "",
|
||||
...INITIAL_FORM_DATA,
|
||||
title: (response.title ?? "").trim() || "Sin título",
|
||||
content: response.content ?? "",
|
||||
syntax: response.syntax || "plaintext",
|
||||
burnAfter: response.burnAfter || false,
|
||||
isPrivate: response.isPrivate || false,
|
||||
password: ""
|
||||
isPrivate: response.isPrivate || false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -236,7 +273,7 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
|
||||
<CodeEditor
|
||||
className="flex-fill custom-border rounded-4 overflow-hidden pt-4 pe-4"
|
||||
syntax={formData.syntax}
|
||||
readOnly={!!selectedPaste}
|
||||
readOnly={isEditorReadOnly}
|
||||
onChange={(val) => handleChange("content", val)}
|
||||
value={formData.content ?? ""}
|
||||
editorErrors={editorErrors}
|
||||
@@ -257,7 +294,7 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
|
||||
<Form.Control
|
||||
disabled={isReadOnly}
|
||||
type="text"
|
||||
value={mode === 'rt' ? `Sesión: ${activeKey}` : formData.title}
|
||||
value={titleValue}
|
||||
onChange={(e) => handleChange("title", e.target.value)}
|
||||
isInvalid={!!fieldErrors.title}
|
||||
/>
|
||||
@@ -274,7 +311,7 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
|
||||
}
|
||||
>
|
||||
<Form.Select
|
||||
disabled={!!selectedPaste}
|
||||
disabled={isReadOnly}
|
||||
value={formData.syntax}
|
||||
onChange={(e) => handleChange("syntax", e.target.value)}
|
||||
>
|
||||
@@ -311,7 +348,7 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
|
||||
</FloatingLabel>
|
||||
|
||||
<div className="d-flex align-items-center ms-1">
|
||||
{isSaving ? (
|
||||
{connected && (isSaving ? (
|
||||
<span className="text-muted" style={{ fontSize: '0.8rem' }}>
|
||||
<FontAwesomeIcon icon={faCircle} className="pulse-animation me-2" style={{ color: '#ffc107', fontSize: '8px' }} />
|
||||
Guardando cambios...
|
||||
@@ -320,7 +357,7 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
|
||||
<span className="text-success" style={{ fontSize: '0.8rem' }}>
|
||||
Cambios guardados
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Form.Check
|
||||
@@ -328,7 +365,7 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
|
||||
disabled={isReadOnly}
|
||||
id="burnAfter"
|
||||
label="volátil"
|
||||
checked={formData.burnAfter}
|
||||
checked={formData.burnAfter && !isReadOnly}
|
||||
onChange={(e) => handleChange("burnAfter", e.target.checked)}
|
||||
className="ms-1 d-flex gap-2 align-items-center"
|
||||
/>
|
||||
@@ -338,12 +375,12 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
|
||||
disabled={isReadOnly}
|
||||
id="isPrivate"
|
||||
label="privado"
|
||||
checked={formData.isPrivate}
|
||||
checked={formData.isPrivate && !isReadOnly}
|
||||
onChange={(e) => handleChange("isPrivate", e.target.checked)}
|
||||
className="ms-1 d-flex gap-2 align-items-center"
|
||||
/>
|
||||
|
||||
{formData.isPrivate && (
|
||||
{formData.isPrivate && !isReadOnly && (
|
||||
<PasswordInput disabled={isReadOnly} onChange={(e) => handleChange("password", e.target.value)} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const trimContent = (text, maxLength = 80) => {
|
||||
const PublicPasteItem = ({ paste, onSelect }) => {
|
||||
return (
|
||||
<div className="public-paste-item p-2 mb-2 rounded custom-border" style={{ cursor: "pointer" }} onClick={() => onSelect(paste.pasteKey)}>
|
||||
<h5 className="m-0">{paste.title}</h5>
|
||||
<h5 className="m-0">{(paste.title ?? "").trim() || "Sin título"}</h5>
|
||||
<p className="m-0 text-truncate">{trimContent(paste.content, 100)}</p>
|
||||
<small className="custom-text-muted">
|
||||
{new Date(paste.createdAt).toLocaleString()}
|
||||
|
||||
@@ -9,22 +9,36 @@ export const ConfigProvider = ({ children }) => {
|
||||
const [configError, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = import.meta.env.MODE === 'production'
|
||||
? await fetch("/config/settings.prod.json")
|
||||
: await fetch("/config/settings.dev.json");
|
||||
const settingsPath = import.meta.env.MODE === 'production'
|
||||
? "/config/settings.prod.json"
|
||||
: "/config/settings.dev.json";
|
||||
|
||||
const response = await fetch(settingsPath);
|
||||
if (!response.ok) throw new Error("Error al cargar settings.*.json");
|
||||
const json = await response.json();
|
||||
if (isMounted) {
|
||||
setConfig(json);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
if (isMounted) {
|
||||
setError(err.message || "Error al cargar configuración");
|
||||
}
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchConfig();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,10 +7,12 @@ export const DataContext = createContext();
|
||||
|
||||
export const DataProvider = ({ config, children }) => {
|
||||
const { showError } = useError();
|
||||
const data = useData(config, showError);
|
||||
// Centraliza errores HTTP para que cualquier consumidor de DataContext
|
||||
// tenga comportamiento homogéneo sin repetir wiring en cada página.
|
||||
const dataApi = useData(config, showError);
|
||||
|
||||
return (
|
||||
<DataContext.Provider value={data}>
|
||||
<DataContext.Provider value={dataApi}>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.carousel-img-wrapper {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.carousel-img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 1rem;
|
||||
max-height: 60vh;
|
||||
object-fit: cover;
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
.footer {
|
||||
background-color: var(--navbar-bg);
|
||||
color: var(--fg-color);
|
||||
border-top: 2px solid var(--border-color);
|
||||
font-size: 1rem;
|
||||
box-shadow: 0 -2px 8px var(--shadow-color);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.footer-title,
|
||||
.footer h6#devd {
|
||||
font-family: "Product Sans";
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.footer-columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
background-color: var(--bg-hover-color);
|
||||
padding: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.footer-columns {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-column {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.footer-column h5 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.footer-column ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer-column ul li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.footer-column ul li a {
|
||||
color: var(--fg-color);
|
||||
text-decoration: none;
|
||||
|
||||
}
|
||||
|
||||
.footer-column ul li a:hover {
|
||||
color: var(--primary-color);
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.85;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.footer-bottom a {
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: var(--fg-color);
|
||||
|
||||
}
|
||||
|
||||
.footer-bottom a:hover {
|
||||
text-shadow: 0 0 5px currentColor;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.95rem;
|
||||
background-color: var(--contact-info-bg);
|
||||
color: var(--primary-color);
|
||||
box-shadow: 0 4px 10px var(--shadow-color);
|
||||
|
||||
}
|
||||
|
||||
.contact-info a {
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
}
|
||||
|
||||
.contact-info a:hover {
|
||||
transform: translateX(8px);
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.contact-info .fa-icon {
|
||||
font-size: 1.2rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.heart-anim {
|
||||
display: inline-block;
|
||||
animation: heartbeat 1.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes heartbeat {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/* ================================
|
||||
HEADER - ESTILO BASE
|
||||
================================== */
|
||||
|
||||
.bg-img {
|
||||
background-image: url('/images/bg.png');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
/*.mask {
|
||||
background-color: var(--header-mask-color);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
|
||||
}
|
||||
*/
|
||||
|
||||
.header-title {
|
||||
font-family: 'Product Sans';
|
||||
font-size: 3em;
|
||||
font-weight: bolder;
|
||||
color: var(--text-color);
|
||||
|
||||
}
|
||||
|
||||
.shadowed {
|
||||
text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
|
||||
/* ================================
|
||||
RESPONSIVE HEADER TITLE
|
||||
================================== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-title {
|
||||
font-size: 2em;
|
||||
padding: 0 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/* ================================
|
||||
LOGIN - CARD CONTAINER (VISUAL)
|
||||
================================== */
|
||||
|
||||
.login-card {
|
||||
background-color: var(--login-bg) !important;
|
||||
color: var(--text-color);
|
||||
box-shadow: 0 0 10px var(--shadow-color);
|
||||
|
||||
}
|
||||
|
||||
/* ================================
|
||||
INPUTS VISUALES
|
||||
================================== */
|
||||
|
||||
input.form-control {
|
||||
background-color: var(--input-bg);
|
||||
color: var(--input-text);
|
||||
border: 1px solid var(--input-border);
|
||||
|
||||
}
|
||||
|
||||
/* ================================
|
||||
LABELS PERSONALIZADAS
|
||||
================================== */
|
||||
|
||||
.form-floating>label {
|
||||
font-family: 'Product Sans';
|
||||
font-size: 1.1em;
|
||||
color: var(--label-color);
|
||||
}
|
||||
|
||||
.form-floating>label::after {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* ================================
|
||||
BOTÓN VISUAL
|
||||
================================== */
|
||||
|
||||
.login-button {
|
||||
font-family: 'Product Sans' !important;
|
||||
font-size: 1.3em !important;
|
||||
font-weight: bold !important;
|
||||
background-color: var(--login-btn-bg) !important;
|
||||
color: var(--login-btn-text) !important;
|
||||
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background-color: var(--login-btn-hover) !important;
|
||||
color: var(--login-btn-text-hover) !important;
|
||||
}
|
||||
@@ -221,11 +221,11 @@ html, body, #root {
|
||||
}
|
||||
|
||||
/* Tipografía global */
|
||||
div,
|
||||
div:not(.monaco-editor *),
|
||||
span:not(.monaco-editor *),
|
||||
label,
|
||||
input,
|
||||
p,
|
||||
span,
|
||||
a,
|
||||
button {
|
||||
font-family: "Open Sans", sans-serif;
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import { AuthContext } from "@/context/AuthContext";
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
export const useAuth = () => {
|
||||
const authContext = useContext(AuthContext);
|
||||
|
||||
if (!authContext) {
|
||||
throw new Error("useAuth debe usarse dentro de AuthProvider");
|
||||
}
|
||||
|
||||
return authContext;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import { ConfigContext } from "@/context/ConfigContext.jsx";
|
||||
|
||||
export const useConfig = () => useContext(ConfigContext);
|
||||
export const useConfig = () => {
|
||||
const configContext = useContext(ConfigContext);
|
||||
|
||||
if (!configContext) {
|
||||
throw new Error("useConfig debe usarse dentro de ConfigProvider");
|
||||
}
|
||||
|
||||
return configContext;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import axios from "axios";
|
||||
|
||||
export const useData = (config, onError) => {
|
||||
@@ -6,8 +6,6 @@ export const useData = (config, onError) => {
|
||||
const [dataLoading, setLoading] = useState(true);
|
||||
const [dataError, setError] = useState(null);
|
||||
|
||||
const configString = JSON.stringify(config);
|
||||
|
||||
const getAuthHeaders = (isFormData = false) => {
|
||||
const token = localStorage.getItem("token");
|
||||
const headers = {};
|
||||
@@ -17,14 +15,20 @@ export const useData = (config, onError) => {
|
||||
};
|
||||
|
||||
const handleAxiosError = (err) => {
|
||||
const errorData = {
|
||||
return {
|
||||
status: err.response?.status || (err.request ? "Network Error" : "Client Error"),
|
||||
message: err.response?.data?.message || err.message || "Error desconocido",
|
||||
errors: err.response?.data?.errors || null
|
||||
};
|
||||
return errorData;
|
||||
};
|
||||
|
||||
const isExpectedPasteLookupError = (baseUrl, status) => {
|
||||
const isPasteLookup = baseUrl?.includes("/pastes/");
|
||||
return isPasteLookup && [403, 404, 500].includes(status);
|
||||
};
|
||||
|
||||
// Carga inicial ligada al `config` del contexto.
|
||||
// En lookup de pastes, algunos estados son esperados y no deben disparar error global.
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!config?.baseUrl) return;
|
||||
|
||||
@@ -39,25 +43,25 @@ export const useData = (config, onError) => {
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
const error = handleAxiosError(err);
|
||||
const isPasteLookup = config.baseUrl.includes('/pastes/');
|
||||
|
||||
if (isPasteLookup && (error.status === 403 || error.status === 404 || error.status === 500)) {
|
||||
console.log("Not in DB, assuming real-time...");
|
||||
if (!isExpectedPasteLookupError(config.baseUrl, error.status) && onError) onError(error);
|
||||
setError(error);
|
||||
} else {
|
||||
if (onError) onError(error);
|
||||
setError(error);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [configString, onError]);
|
||||
}, [config?.baseUrl, config?.params, onError]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const requestWrapper = async (method, endpoint, payload = null, refresh = false, extraHeaders = {}, silent = false) => {
|
||||
// Wrapper único para peticiones CRUD.
|
||||
// Usa objeto de opciones para mantener llamadas claras y evitar errores por orden de argumentos.
|
||||
const requestWrapper = async (method, endpoint, {
|
||||
payload = null,
|
||||
refresh = false,
|
||||
extraHeaders = {},
|
||||
silent = false,
|
||||
} = {}) => {
|
||||
try {
|
||||
const isFormData = payload instanceof FormData;
|
||||
|
||||
@@ -98,9 +102,49 @@ export const useData = (config, onError) => {
|
||||
|
||||
return {
|
||||
data, dataLoading, dataError,
|
||||
getData: (url, params, refresh = true, h = {}, silent = false) => requestWrapper("get", url, params, refresh, h, silent),
|
||||
postData: (url, body, refresh = true, silent = false) => requestWrapper("post", url, body, refresh, silent),
|
||||
putData: (url, body, refresh = true, silent = false) => requestWrapper("put", url, body, refresh, silent),
|
||||
deleteData: (url, refresh = true, silent = false) => requestWrapper("delete", url, null, refresh, silent),
|
||||
getData: (url, paramsOrOptions, refresh = true, h = {}, silent = false) => {
|
||||
const isOptionsObject =
|
||||
paramsOrOptions &&
|
||||
typeof paramsOrOptions === "object" &&
|
||||
!Array.isArray(paramsOrOptions) &&
|
||||
("params" in paramsOrOptions || "refresh" in paramsOrOptions || "headers" in paramsOrOptions || "silent" in paramsOrOptions);
|
||||
|
||||
if (isOptionsObject) {
|
||||
const {
|
||||
params = null,
|
||||
refresh: optionsRefresh = true,
|
||||
headers = {},
|
||||
silent: optionsSilent = false,
|
||||
} = paramsOrOptions;
|
||||
|
||||
return requestWrapper("get", url, {
|
||||
payload: params,
|
||||
refresh: optionsRefresh,
|
||||
extraHeaders: headers,
|
||||
silent: optionsSilent,
|
||||
});
|
||||
}
|
||||
|
||||
return requestWrapper("get", url, {
|
||||
payload: paramsOrOptions,
|
||||
refresh,
|
||||
extraHeaders: h,
|
||||
silent,
|
||||
});
|
||||
},
|
||||
postData: (url, body, refresh = true, silent = false) => requestWrapper("post", url, {
|
||||
payload: body,
|
||||
refresh,
|
||||
silent,
|
||||
}),
|
||||
putData: (url, body, refresh = true, silent = false) => requestWrapper("put", url, {
|
||||
payload: body,
|
||||
refresh,
|
||||
silent,
|
||||
}),
|
||||
deleteData: (url, refresh = true, silent = false) => requestWrapper("delete", url, {
|
||||
refresh,
|
||||
silent,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
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;
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import '@/css/Building.css';
|
||||
|
||||
export default function Building() {
|
||||
const location = useLocation();
|
||||
|
||||
if (location.pathname === '/') return null;
|
||||
|
||||
return (
|
||||
<div className="building-container d-flex flex-column align-items-center justify-content-center text-center py-5 px-3">
|
||||
<div className="building-icon">🚧</div>
|
||||
<div className="building-title">Esta página está en construcción</div>
|
||||
<div className="building-subtitle">Estamos trabajando para traértela pronto</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
@@ -7,63 +7,53 @@ import { useState, useMemo } from 'react';
|
||||
import { DataProvider } from '@/context/DataContext';
|
||||
import NotificationModal from '@/components/NotificationModal';
|
||||
import { useSearch } from "@/context/SearchContext";
|
||||
import { useError } from '@/context/ErrorContext';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
|
||||
const Home = ({ mode, onConnectChange }) => {
|
||||
const { pasteKey, rtKey } = useParams();
|
||||
const { config, configLoading } = useConfig();
|
||||
const { showError } = useError();
|
||||
const location = useLocation();
|
||||
const isStaticMode = mode === 'static';
|
||||
|
||||
const currentKey = mode === 'static' ? pasteKey : rtKey;
|
||||
const currentKey = isStaticMode ? pasteKey : rtKey;
|
||||
|
||||
const reqConfig = useMemo(() => {
|
||||
const requestConfig = useMemo(() => {
|
||||
if (!config?.apiConfig?.baseUrl) return null;
|
||||
|
||||
const baseApi = `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.pastes.all}`;
|
||||
|
||||
if (mode === 'static' && currentKey) {
|
||||
return {
|
||||
baseUrl: `${baseApi}/s/${currentKey}`,
|
||||
params: {}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: baseApi,
|
||||
params: {}
|
||||
};
|
||||
}, [config, mode, currentKey]);
|
||||
}, [config]);
|
||||
|
||||
if (configLoading) return <p className="text-center mt-5"><LoadingIcon /></p>;
|
||||
|
||||
if (mode === 'static' && !reqConfig?.baseUrl?.includes('/s/')) {
|
||||
return <div className="text-center mt-5"><LoadingIcon /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DataProvider key={location.key} config={reqConfig} onError={showError}>
|
||||
<HomeContent reqConfig={reqConfig} mode={mode} pasteKey={currentKey} onConnectChange={onConnectChange} />
|
||||
<DataProvider key={location.key} config={requestConfig}>
|
||||
<HomeContent requestConfig={requestConfig} mode={mode} pasteKey={currentKey} onConnectChange={onConnectChange} />
|
||||
</DataProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const HomeContent = ({ reqConfig, mode, pasteKey, onConnectChange }) => {
|
||||
const HomeContent = ({ requestConfig, mode, pasteKey, onConnectChange }) => {
|
||||
const { data, dataLoading, postData } = useDataContext();
|
||||
const [createdKey, setCreatedKey] = useState(null);
|
||||
const { searchTerm } = useSearch();
|
||||
const isStaticMode = mode === 'static';
|
||||
|
||||
if (mode === 'static' && dataLoading) return <p className="text-center mt-5"><LoadingIcon /></p>;
|
||||
const filteredPublicPastes = useMemo(() => {
|
||||
if (!Array.isArray(data)) return [];
|
||||
const normalizedSearchTerm = (searchTerm ?? "").toLowerCase();
|
||||
return data.filter((paste) => (paste.title ?? "").toLowerCase().includes(normalizedSearchTerm));
|
||||
}, [data, searchTerm]);
|
||||
|
||||
const filtered = (data && Array.isArray(data)) ? data.filter(paste =>
|
||||
paste.title.toLowerCase().includes((searchTerm ?? "").toLowerCase())
|
||||
) : [];
|
||||
if (isStaticMode && dataLoading) return <p className="text-center mt-5"><LoadingIcon /></p>;
|
||||
|
||||
const handleSubmit = async (paste, isAutosave = false) => {
|
||||
try {
|
||||
const createdPaste = await postData(reqConfig.baseUrl, paste);
|
||||
const createdPaste = await postData(requestConfig.baseUrl, paste);
|
||||
if (!isAutosave && createdPaste && !paste.pasteKey) {
|
||||
setCreatedKey(createdPaste.pasteKey);
|
||||
}
|
||||
@@ -76,7 +66,7 @@ const HomeContent = ({ reqConfig, mode, pasteKey, onConnectChange }) => {
|
||||
<>
|
||||
<PastePanel
|
||||
onSubmit={handleSubmit}
|
||||
publicPastes={filtered}
|
||||
publicPastes={filteredPublicPastes}
|
||||
mode={mode}
|
||||
pasteKey={pasteKey}
|
||||
onConnectChange={onConnectChange}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export const renderErrorAlert = (error, options = {}) => {
|
||||
const { className = 'alert alert-danger alert-dismissible 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);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const CONSTANTS = {
|
||||
|
||||
};
|
||||
|
||||
export { CONSTANTS };
|
||||
@@ -1,10 +0,0 @@
|
||||
'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 }
|
||||
@@ -1,30 +0,0 @@
|
||||
export const DateParser = {
|
||||
sqlToString: (sqlDate) => {
|
||||
const [datePart] = sqlDate.split('T');
|
||||
const [year, month, day] = datePart.split('-');
|
||||
return `${day}/${month}/${year}`;
|
||||
},
|
||||
|
||||
timestampToString: (timestamp) => {
|
||||
const [datePart] = timestamp.split('T');
|
||||
const [year, month, day] = datePart.split('-');
|
||||
return `${day}/${month}/${year}`;
|
||||
},
|
||||
|
||||
isoToStringWithTime: (isoString) => {
|
||||
if (!isoString) return '—';
|
||||
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date)) return '—'; // Para proteger aún más por si llega basura
|
||||
|
||||
return new Intl.DateTimeFormat('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'Europe/Madrid'
|
||||
}).format(date);
|
||||
}
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
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";
|
||||
}
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
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('');
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
export const parseJwt = (token) => {
|
||||
try {
|
||||
return JSON.parse(atob(token.split('.')[1]));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -16,5 +16,20 @@ export default defineConfig({
|
||||
},
|
||||
define: {
|
||||
global: 'window'
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('monaco-editor')) {
|
||||
return 'monaco';
|
||||
}
|
||||
return 'vendor';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
chunkSizeWarningLimit: 1500,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user