Compare commits

..

8 Commits

Author SHA1 Message Date
Jose
3a848b3f56 refactor: improve read-only logic and title handling in PastePanel 2026-03-17 18:22:59 +01:00
Jose
d9eb92300a fix: font problem 2026-03-17 18:05:01 +01:00
Jose
bf40b235f0 refactor: remove unused components and styles; consolidate password handling in new components
- Deleted ProtectedRoute, ContentWrapper, CustomCarousel, CustomContainer, CustomModal, Footer, Header, and Building components as they were no longer needed.
- Removed associated CSS files for the deleted components.
- Introduced PasswordInput and PasswordModal components to handle password input and modal display for protected pastes.
- Updated PastePanel to utilize new PasswordInput and PasswordModal components for better password management.
- Refactored Home component to streamline data fetching and improve readability.
- Enhanced error handling in useData hook and improved session management logic.
2026-03-17 03:57:47 +01:00
Jose
fcd477e876 Improve: enhance real-time paste handling and add saving status feedback 2026-03-17 03:08:38 +01:00
Jose
924f9626a6 feat: add real-time collaboration features with STOMP and SockJS
- Added @stomp/stompjs and sockjs-client dependencies for WebSocket communication.
- Updated routing for pastes to include new endpoint structure.
- Implemented real-time editing in PastePanel using STOMP for collaborative editing.
- Introduced NotificationModal for experimental mode warnings.
- Enhanced NavBar to display connection status.
- Refactored Home and PastePanel components to support new features and improve user experience.
- Updated error handling in DataContext to utilize ErrorContext for better error management.
- Added CSS animations for connection status indication.
2026-03-17 02:25:59 +01:00
Jose
f29d82f311 Improve: error handling and data fetching in PastePanel and useData hooks 2026-03-16 01:20:01 +01:00
Jose
69140e6da1 improved: error handling, form state, paste fetching and monaco editor config 2026-03-07 21:51:47 +01:00
Jose
4d0c4d3f26 Add: migration to new backend 2026-02-24 03:39:49 +01:00
51 changed files with 5329 additions and 1450 deletions

View File

@@ -1,13 +1,16 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title style="font-family: Fira Code;">mpaste</title> <title style="font-family: Fira Code;">mpaste</title>
</head> </head>
<body>
<body>
<div id="root" class="p-0 m-0"></div> <div id="root" class="p-0 m-0"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
</html> </html>

4657
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@
"@fortawesome/free-solid-svg-icons": "^7.0.0", "@fortawesome/free-solid-svg-icons": "^7.0.0",
"@fortawesome/react-fontawesome": "^0.2.3", "@fortawesome/react-fontawesome": "^0.2.3",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@stomp/stompjs": "^7.3.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"bootstrap": "^5.3.7", "bootstrap": "^5.3.7",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -26,7 +27,8 @@
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^7.7.1", "react-router-dom": "^7.7.1",
"react-slick": "^0.30.3", "react-slick": "^0.30.3",
"slick-carousel": "^1.8.1" "slick-carousel": "^1.8.1",
"sockjs-client": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.30.1", "@eslint/js": "^9.30.1",

View File

@@ -1,11 +1,11 @@
{ {
"apiConfig": { "apiConfig": {
"baseUrl": "https://api.miarma.net/mpaste", "baseUrl": "http://localhost:8081/v2/mpaste",
"endpoints": { "endpoints": {
"pastes": { "pastes": {
"all": "/raw/v1/pastes", "all": "/pastes",
"byId": "/raw/v1/pastes/:paste_id", "byId": "/by-id/:pasteId",
"byKey": "/v1/pastes/:paste_key" "byKey": "/s/:pasteKey"
} }
} }
} }

View File

@@ -1,11 +1,11 @@
{ {
"apiConfig": { "apiConfig": {
"baseUrl": "https://api.miarma.net/mpaste", "baseUrl": "https://api.miarma.net/v2/mpaste",
"endpoints": { "endpoints": {
"pastes": { "pastes": {
"all": "/raw/v1/pastes", "all": "/pastes",
"byId": "/raw/v1/pastes/:paste_id", "byId": "/by-id/:pasteId",
"byKey": "/v1/pastes/:paste_key" "byKey": "/s/:pasteKey"
} }
} }
} }

View File

@@ -1,17 +1,54 @@
import NavBar from '@/components/NavBar.jsx'; import NavBar from '@/components/NavBar.jsx';
import { Route, Routes, useLocation } from 'react-router-dom' import { Route, Routes, useLocation } from 'react-router-dom'
import Home from '@/pages/Home.jsx' import Home from '@/pages/Home.jsx'
import { useState, useEffect } from 'react';
import NotificationModal from './components/NotificationModal';
function App() { function App() {
const [connected, setConnected] = useState(false);
const [showExperimentalModal, setShowExperimentalModal] = useState(false);
useEffect(() => {
const hasSeenWarning = localStorage.getItem('experimental_rt_warned');
if (!hasSeenWarning) {
setShowExperimentalModal(true);
}
}, []);
const handleCloseWarning = () => {
localStorage.setItem('experimental_rt_warned', 'true');
setShowExperimentalModal(false);
};
return ( return (
<> <>
<NavBar /> <NavBar connected={connected} />
<div className="fill d-flex flex-column"> <div className="fill d-flex flex-column">
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home mode="create" onConnectChange={setConnected} />} />
<Route path="/:paste_key" element={<Home />} /> <Route path="/s/:pasteKey" element={<Home mode="static" onConnectChange={setConnected} />} />
<Route path="/:rtKey" element={<Home mode="rt" onConnectChange={setConnected} />} />
</Routes> </Routes>
</div> </div>
<NotificationModal
show={showExperimentalModal}
onClose={handleCloseWarning}
title="Modo Experimental"
message={
<span>
He añadido un modo tiempo real pero de momento es <strong>EXPERIMENTAL</strong>. Cualquier fallo por favor mandadlo a jose [arroba] miarma.net.
</span>
}
variant="warning"
buttons={[
{
label: "Vale",
variant: "warning",
onClick: handleCloseWarning
}
]}
/>
</> </>
) )
} }

View File

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

View File

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

View File

@@ -1,8 +0,0 @@
import { useAuth } from "@/hooks/useAuth.js";
const IfAuthenticated = ({ children }) => {
const { authStatus } = useAuth();
return authStatus === "authenticated" ? children : null;
};
export default IfAuthenticated;

View File

@@ -1,8 +0,0 @@
import { useAuth } from "@/hooks/useAuth.js";
const IfNotAuthenticated = ({ children }) => {
const { authStatus } = useAuth();
return authStatus === "unauthenticated" ? children : null;
};
export default IfNotAuthenticated;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,10 @@ import { Navbar, Nav, Container } from 'react-bootstrap';
import SearchToolbar from './SearchToolbar'; import SearchToolbar from './SearchToolbar';
import { useSearch } from "@/context/SearchContext"; import { useSearch } from "@/context/SearchContext";
import NotificationModal from './NotificationModal'; import NotificationModal from './NotificationModal';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircle } from '@fortawesome/free-solid-svg-icons';
const NavBar = () => { const NavBar = ({ connected }) => {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [isLg, setIsLg] = useState(window.innerWidth >= 992); const [isLg, setIsLg] = useState(window.innerWidth >= 992);
const [isXs, setIsXs] = useState(window.innerWidth < 576); const [isXs, setIsXs] = useState(window.innerWidth < 576);
@@ -47,7 +49,6 @@ const NavBar = () => {
className='shadow-none custom-border-bottom' className='shadow-none custom-border-bottom'
> >
<Container fluid> <Container fluid>
{/* brand */}
<Nav.Item <Nav.Item
title="mpaste" title="mpaste"
className={`navbar-brand`} className={`navbar-brand`}
@@ -58,12 +59,10 @@ const NavBar = () => {
</div> </div>
</Nav.Item> </Nav.Item>
{/* ThemeButton SIEMPRE fijo */}
<div className="order-lg-2 ms-auto me-2"> <div className="order-lg-2 ms-auto me-2">
<ThemeButton onlyIcon={isXs} /> <ThemeButton onlyIcon={isXs} />
</div> </div>
{/* burger */}
<Navbar.Toggle <Navbar.Toggle
aria-controls="main-navbar" aria-controls="main-navbar"
className="custom-toggler border-0 order-lg-3" className="custom-toggler border-0 order-lg-3"
@@ -79,7 +78,6 @@ const NavBar = () => {
</svg> </svg>
</Navbar.Toggle> </Navbar.Toggle>
{/* links y search que colapsan */}
<Navbar.Collapse id="main-navbar" className="order-lg-1"> <Navbar.Collapse id="main-navbar" className="order-lg-1">
<Nav <Nav
className={`me-auto gap-3 w-100 ${expanded ? "flex-column align-items-start mt-3 mb-2" : "d-flex align-items-center"}`} className={`me-auto gap-3 w-100 ${expanded ? "flex-column align-items-start mt-3 mb-2" : "d-flex align-items-center"}`}
@@ -92,11 +90,24 @@ const NavBar = () => {
onSearchChange={setSearchTerm} onSearchChange={setSearchTerm}
/> />
</div> </div>
<div className="d-flex align-items-center gap-2 ms-2 px-2 py-1 " style={{ fontSize: '0.85rem' }}>
<FontAwesomeIcon
icon={faCircle}
className={connected ? "pulse-animation" : ""}
style={{
color: connected ? '#28a745' : '#dc3545',
fontSize: '10px',
filter: connected ? 'drop-shadow(0 0 4px #28a745)' : 'none'
}}
/>
<span className="text-secondary fw-medium">
{connected ? 'conectado' : 'desconectado'}
</span>
</div>
</Nav> </Nav>
</Navbar.Collapse> </Navbar.Collapse>
</Container> </Container>
</Navbar> </Navbar>
{/* Contact Modal */}
<NotificationModal <NotificationModal
show={showContactModal} show={showContactModal}
onClose={() => setShowContactModal(false)} onClose={() => setShowContactModal(false)}

View File

@@ -1,23 +1,29 @@
import Editor from "@monaco-editor/react"; import Editor from "@monaco-editor/react";
import { useTheme } from "@/hooks/useTheme"; import { useTheme } from "@/hooks/useTheme";
import { useRef } from "react"; import { useEffect, useRef } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { loader } from '@monaco-editor/react'; import * as monaco from "monaco-editor";
loader.config({ const CodeEditor = ({ className = "", syntax, readOnly, onChange, value, editorErrors = [] }) => {
'vs/nls': {
availableLanguages: { '*': 'es' },
},
});
const CodeEditor = ({ className = "", syntax, readOnly, onChange, value }) => {
const { theme } = useTheme(); const { theme } = useTheme();
const editorRef = useRef(null); const editorRef = useRef(null);
const onMount = (editor) => { useEffect(() => {
editorRef.current = editor; if (!editorRef.current) return;
editor.focus(); const model = editorRef.current.getModel();
} if (!model) return;
monaco.editor.setModelMarkers(model, "owner", editorErrors.map(err => ({
startLineNumber: err.lineNumber,
startColumn: 1,
endLineNumber: err.lineNumber,
endColumn: model.getLineLength(err.lineNumber) + 1,
message: err.message,
severity: monaco.MarkerSeverity.Error
})));
}, [editorErrors]);
const onMount = (editor) => { editorRef.current = editor; editor.focus(); }
return ( return (
<div className={`code-editor ${className}`}> <div className={`code-editor ${className}`}>
@@ -25,22 +31,18 @@ const CodeEditor = ({ className = "", syntax, readOnly, onChange, value }) => {
language={syntax || "plaintext"} language={syntax || "plaintext"}
value={value || ""} value={value || ""}
theme={theme === "dark" ? "vs-dark" : "vs-light"} theme={theme === "dark" ? "vs-dark" : "vs-light"}
onChange={(value) => onChange?.(value)} onChange={onChange}
onMount={onMount} onMount={onMount}
options={{ options={{
minimap: { enabled: false }, minimap: { enabled: true },
automaticLayout: true, automaticLayout: true,
fontFamily: 'Fira Code', fontFamily: 'Fira Code',
fontLigatures: true, fontLigatures: true,
fontSize: 18, fontSize: 18,
lineHeight: 1.5, lineHeight: 1.5,
scrollbar: { verticalScrollbarSize: 0 }, scrollbar: { verticalScrollbarSize: 10 },
wordWrap: "on", wordWrap: "on",
formatOnPaste: true, formatOnPaste: true,
suggest: {
showFields: true,
showFunctions: true,
},
readOnly: readOnly || false, readOnly: readOnly || false,
}} }}
/> />
@@ -54,6 +56,7 @@ CodeEditor.propTypes = {
readOnly: PropTypes.bool, readOnly: PropTypes.bool,
onChange: PropTypes.func, onChange: PropTypes.func,
value: PropTypes.string, value: PropTypes.string,
editorErrors: PropTypes.array,
}; };
export default CodeEditor; export default CodeEditor;

View File

@@ -5,7 +5,7 @@ import '@/css/PasswordInput.css';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEye, faEyeSlash, faKey } from '@fortawesome/free-solid-svg-icons'; import { faEye, faEyeSlash, faKey } from '@fortawesome/free-solid-svg-icons';
const PasswordInput = ({ value, onChange, name = "password" }) => { const PasswordInput = ({ disabled, value, onChange, name = "password" }) => {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const toggleShow = () => setShow(prev => !prev); const toggleShow = () => setShow(prev => !prev);
@@ -28,6 +28,7 @@ const PasswordInput = ({ value, onChange, name = "password" }) => {
placeholder="" placeholder=""
onChange={onChange} onChange={onChange}
className="rounded-4 pe-5" className="rounded-4 pe-5"
disabled={disabled}
/> />
</FloatingLabel> </FloatingLabel>

View File

@@ -3,8 +3,7 @@ import { Modal, Button, Form } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons'; import { faLock } from '@fortawesome/free-solid-svg-icons';
import { useState } from 'react'; import { useState } from 'react';
import PasswordInput from '@/components/Auth/PasswordInput'; import PasswordInput from '@/components/Pastes/PasswordInput';
import { renderErrorAlert } from '@/util/alertHelpers';
import '@/css/PasswordModal.css'; import '@/css/PasswordModal.css';
const PasswordModal = ({ const PasswordModal = ({
@@ -37,8 +36,6 @@ const PasswordModal = ({
Esta paste está protegida con contraseña. Introduce la clave para continuar. Esta paste está protegida con contraseña. Introduce la clave para continuar.
</p> </p>
{renderErrorAlert(error)}
<PasswordInput <PasswordInput
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}

View File

@@ -1,88 +1,252 @@
import { useState, useEffect } from "react"; 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 '@/css/PastePanel.css';
import PasswordInput from "@/components/Auth/PasswordInput"; import PasswordInput from "@/components/Pastes/PasswordInput";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCode, faHeader } from "@fortawesome/free-solid-svg-icons"; import { faCircle, faCode, faHeader } from "@fortawesome/free-solid-svg-icons";
import CodeEditor from "./CodeEditor"; import CodeEditor from "./CodeEditor";
import PublicPasteItem from "./PublicPasteItem"; import PublicPasteItem from "./PublicPasteItem";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { useDataContext } from "@/hooks/useDataContext"; 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 PastePanel = ({ onSubmit, publicPastes }) => { const INITIAL_FORM_DATA = {
const { paste_key } = useParams(); 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 navigate = useNavigate();
const { getData } = useDataContext(); const { getData } = useDataContext();
const [title, setTitle] = useState(""); const { showError } = useError();
const [content, setContent] = useState("");
const [syntax, setSyntax] = useState(""); const activeKey = propKey || urlPasteKey || rtKey;
const [burnAfter, setBurnAfter] = useState(false);
const [isPrivate, setIsPrivate] = useState(false);
const [password, setPassword] = useState("");
const [selectedPaste, setSelectedPaste] = useState(null); const [selectedPaste, setSelectedPaste] = useState(null);
const [error, setError] = useState(null); const [editorErrors, setEditorErrors] = useState([]);
const [fieldErrors, setFieldErrors] = useState({});
const [showPasswordModal, setShowPasswordModal] = useState(false); const [showPasswordModal, setShowPasswordModal] = useState(false);
const [stompClient, setStompClient] = useState(null);
const [connected, setConnected] = useState(null);
const [isSaving, setIsSaving] = useState(false);
const [formData, setFormData] = useState({ ...INITIAL_FORM_DATA });
const handleSubmit = (e) => { const lastSavedContent = useRef(formData.content);
e.preventDefault();
const paste = {
title,
content,
syntax,
burn_after: burnAfter,
is_private: isPrivate,
password: password || null,
};
if (onSubmit) onSubmit(paste);
};
const handleSelectPaste = async (key) => { const isStaticView = mode === 'static' && !!activeKey;
navigate(`/${key}`); const isReadOnly = isStaticView || mode === 'rt';
}; const isEditorReadOnly = isStaticView;
const titleValue = mode === 'rt'
? `Sesión: ${activeKey}`
: (selectedPaste?.title ?? formData.title ?? "");
const isRemoteChange = useRef(false);
const fetchPaste = async (key, pwd = "") => { // Sincroniza el panel cuando cambia el modo o la clave activa:
const url = `https://api.miarma.net/mpaste/v1/pastes/${key}`; // - modo static: intenta cargar la paste seleccionada
const { data, error } = await getData(url, {}, { // - modo create: reinicia todo el formulario y errores
'X-Paste-Password': pwd useEffect(() => {
if (mode === 'static' && activeKey) {
fetchPaste(activeKey);
} else if (mode === 'create') {
setSelectedPaste(null);
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'
? `https://api.miarma.net/v2/mpaste/ws`
: `http://localhost:8081/v2/mpaste/ws`;
const socket = new SockJS(socketUrl);
const client = new Client({
webSocketFactory: () => socket,
onConnect: () => {
setConnected(true);
onConnectChange(true);
client.subscribe(`/topic/session/${activeKey}`, (message) => {
try {
const remoteState = JSON.parse(message.body);
setFormData(prev => {
if (prev.content === remoteState.content && prev.syntax === remoteState.syntax) {
return prev;
}
isRemoteChange.current = true;
return {
...prev,
...remoteState
};
});
} catch (e) {
console.error("Error parseando el mensaje del socket", e);
}
});
client.publish({ destination: `/app/join/${activeKey}` });
},
onDisconnect: () => {
setConnected(false);
onConnectChange(false);
}
}); });
if (error) { client.activate();
if (error?.status === 401) { setStompClient(client);
setShowPasswordModal(true); return () => client.deactivate();
return;
} else { } else {
setError(error); setConnected(false);
setSelectedPaste(null);
return;
} }
}, [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) {
if (isRemoteChange.current) {
lastSavedContent.current = formData.content;
isRemoteChange.current = false;
return;
} }
setError(null); if (formData.content !== lastSavedContent.current) {
setSelectedPaste(data); const timer = setTimeout(async () => {
setTitle(data.title); setIsSaving(true);
setContent(data.content); try {
setSyntax(data.syntax || "plaintext"); const dataToSave = {
...formData,
pasteKey: activeKey,
isRt: true,
title: mode === 'rt' ? `Sesión: ${activeKey}` : formData.title
};
await onSubmit(dataToSave, true);
lastSavedContent.current = formData.content;
console.log("Autosave");
} catch (err) {
console.error("Error autosaving:", err);
} finally {
setIsSaving(false);
}
}, 5000);
return () => clearTimeout(timer);
}
}
}, [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);
if (connected && stompClient && activeKey) {
stompClient.publish({
destination: `/app/edit/${activeKey}`,
body: JSON.stringify(updatedData)
});
}
}; };
useEffect(() => { const handleSubmit = async (e) => {
if (paste_key) fetchPaste(paste_key); e.preventDefault();
}, [paste_key]); setFieldErrors({});
setEditorErrors([]);
const normalizedTitle = (formData.title ?? "").trim();
const payload = {
...formData,
title: formData.isPrivate
? (normalizedTitle || "Sin título")
: formData.title
};
try {
if (onSubmit) await onSubmit(payload);
} catch (error) {
if (error.status === 422 && error.errors) {
const newFieldErrors = {};
Object.entries(error.errors).forEach(([field, msg]) => {
if (field === "content") {
setEditorErrors([{ lineNumber: 1, message: msg }]);
} else {
newFieldErrors[field] = msg;
}
});
setFieldErrors(newFieldErrors);
} else {
showError(error);
}
}
};
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}`
: `http://localhost:8081/v2/mpaste/pastes/s/${key}`;
const headers = pwd ? { "X-Paste-Password": pwd } : {};
try {
const response = await getData(url, {
params: null,
refresh: false,
headers,
silent: true,
});
if (response) {
setSelectedPaste(response);
setShowPasswordModal(false);
setFormData({
...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
});
}
} catch (error) {
const status = error?.status ?? error?.response?.status;
if (status === 403) {
setShowPasswordModal(true);
return;
}
if (status === 404) {
setShowPasswordModal(false);
navigate("/", { replace: true });
}
}
};
return ( return (
<> <>
<div className="paste-panel border-0 flex-fill d-flex flex-column min-h-0 p-3"> <div className="paste-panel border-0 flex-fill d-flex flex-column min-h-0 p-3">
{error &&
<Alert variant="danger" onClose={() => setError(null)} dismissible>
<strong>
<span className="text-danger">{
error.status == 404 ? "404: Paste no encontrada." :
"Ha ocurrido un error al cargar la paste."
}</span>
</strong>
</Alert>
}
<Form onSubmit={handleSubmit} className="flex-fill d-flex flex-column min-h-0"> <Form onSubmit={handleSubmit} className="flex-fill d-flex flex-column min-h-0">
<Row className="g-3 flex-fill min-h-0"> <Row className="g-3 flex-fill min-h-0">
<Col xs={12} lg={2} className="order-last order-lg-first d-flex flex-column flex-fill min-h-0 overflow-hidden"> <Col xs={12} lg={2} className="order-last order-lg-first d-flex flex-column flex-fill min-h-0 overflow-hidden">
@@ -93,7 +257,7 @@ const PastePanel = ({ onSubmit, publicPastes }) => {
{publicPastes && publicPastes.length > 0 ? ( {publicPastes && publicPastes.length > 0 ? (
publicPastes.map((paste) => ( publicPastes.map((paste) => (
<PublicPasteItem <PublicPasteItem
key={paste.paste_key} key={paste.pasteKey}
paste={paste} paste={paste}
onSelect={handleSelectPaste} onSelect={handleSelectPaste}
/> />
@@ -108,31 +272,33 @@ const PastePanel = ({ onSubmit, publicPastes }) => {
<Col xs={12} lg={7} className="d-flex flex-column flex-fill min-h-0 overflow-hidden"> <Col xs={12} lg={7} className="d-flex flex-column flex-fill min-h-0 overflow-hidden">
<CodeEditor <CodeEditor
className="flex-fill custom-border rounded-4 overflow-hidden pt-4 pe-4" className="flex-fill custom-border rounded-4 overflow-hidden pt-4 pe-4"
syntax={syntax} syntax={formData.syntax}
readOnly={!!selectedPaste} readOnly={isEditorReadOnly}
onChange={selectedPaste ? undefined : setContent} onChange={(val) => handleChange("content", val)}
value={content} value={formData.content ?? ""}
editorErrors={editorErrors}
/> />
</Col> </Col>
<Col xs={12} lg={3} className="d-flex flex-column flex-fill min-h-0 overflow-hidden"> <Col xs={12} lg={3} className="d-flex flex-column flex-fill min-h-0 overflow-hidden">
<div className="d-flex flex-column flex-fill gap-3 overflow-auto"> <div className="d-flex flex-column flex-fill gap-3 overflow-auto p-1">
<FloatingLabel <FloatingLabel
controlId="titleInput" controlId="titleInput"
label={ label={
<span className={selectedPaste ? "text-white" : ""}> <span className={isReadOnly ? "text-white" : ""}>
<FontAwesomeIcon icon={faHeader} className="me-2" /> <FontAwesomeIcon icon={faHeader} className="me-2" />
Título Título
</span> </span>
} }
> >
<Form.Control <Form.Control
disabled={!!selectedPaste} disabled={isReadOnly}
type="text" type="text"
placeholder="Título de la paste" value={titleValue}
value={title} onChange={(e) => handleChange("title", e.target.value)}
onChange={(e) => setTitle(e.target.value)} isInvalid={!!fieldErrors.title}
/> />
<Form.Control.Feedback type="invalid">{fieldErrors.title}</Form.Control.Feedback>
</FloatingLabel> </FloatingLabel>
<FloatingLabel <FloatingLabel
@@ -145,9 +311,9 @@ const PastePanel = ({ onSubmit, publicPastes }) => {
} }
> >
<Form.Select <Form.Select
disabled={!!selectedPaste} disabled={isReadOnly}
value={syntax} value={formData.syntax}
onChange={(e) => setSyntax(e.target.value)} onChange={(e) => handleChange("syntax", e.target.value)}
> >
<option value="">Sin resaltado</option> <option value="">Sin resaltado</option>
<option value="javascript">JavaScript</option> <option value="javascript">JavaScript</option>
@@ -181,35 +347,48 @@ const PastePanel = ({ onSubmit, publicPastes }) => {
</Form.Select> </Form.Select>
</FloatingLabel> </FloatingLabel>
<div className="d-flex align-items-center ms-1">
{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...
</span>
) : (
<span className="text-success" style={{ fontSize: '0.8rem' }}>
Cambios guardados
</span>
))}
</div>
<Form.Check <Form.Check
type="switch" type="switch"
disabled={!!selectedPaste} disabled={isReadOnly}
id="burnAfter" id="burnAfter"
label="volátil" label="volátil"
checked={burnAfter} checked={formData.burnAfter && !isReadOnly}
onChange={(e) => setBurnAfter(e.target.checked)} onChange={(e) => handleChange("burnAfter", e.target.checked)}
className="ms-1 d-flex gap-2 align-items-center" className="ms-1 d-flex gap-2 align-items-center"
/> />
<Form.Check <Form.Check
type="switch" type="switch"
disabled={!!selectedPaste} disabled={isReadOnly}
id="isPrivate" id="isPrivate"
label="privado" label="privado"
checked={isPrivate} checked={formData.isPrivate && !isReadOnly}
onChange={(e) => setIsPrivate(e.target.checked)} onChange={(e) => handleChange("isPrivate", e.target.checked)}
className="ms-1 d-flex gap-2 align-items-center" className="ms-1 d-flex gap-2 align-items-center"
/> />
{isPrivate && ( {formData.isPrivate && !isReadOnly && (
<PasswordInput onChange={(e) => setPassword(e.target.value)} /> <PasswordInput disabled={isReadOnly} onChange={(e) => handleChange("password", e.target.value)} />
)} )}
<div className="d-flex justify-content-end"> <div className="d-flex justify-content-end">
<Button <Button
variant="primary" variant="primary"
type="submit" type="submit"
disabled={!!selectedPaste} disabled={isReadOnly}
> >
Crear paste Crear paste
</Button> </Button>
@@ -224,7 +403,7 @@ const PastePanel = ({ onSubmit, publicPastes }) => {
onClose={() => setShowPasswordModal(false)} onClose={() => setShowPasswordModal(false)}
onSubmit={(pwd) => { onSubmit={(pwd) => {
setShowPasswordModal(false); setShowPasswordModal(false);
fetchPaste(paste_key, pwd); // reintentas con la pass fetchPaste(activeKey, pwd);
}} }}
/> />
</> </>

View File

@@ -8,11 +8,11 @@ const trimContent = (text, maxLength = 80) => {
const PublicPasteItem = ({ paste, onSelect }) => { const PublicPasteItem = ({ paste, onSelect }) => {
return ( return (
<div className="public-paste-item p-2 mb-2 rounded custom-border" style={{ cursor: "pointer" }} onClick={() => onSelect(paste.paste_key)}> <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> <p className="m-0 text-truncate">{trimContent(paste.content, 100)}</p>
<small className="custom-text-muted"> <small className="custom-text-muted">
{new Date(paste.created_at).toLocaleString()} {new Date(paste.createdAt).toLocaleString()}
</small> </small>
</div> </div>
); );
@@ -22,7 +22,7 @@ PublicPasteItem.propTypes = {
paste: PropTypes.shape({ paste: PropTypes.shape({
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
content: PropTypes.string.isRequired, content: PropTypes.string.isRequired,
created_at: PropTypes.string.isRequired, createdAt: PropTypes.string.isRequired,
}).isRequired, }).isRequired,
}; };

View File

@@ -1,98 +0,0 @@
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 BASE_URL = config.apiConfig.baseUrl;
const VALIDATE_URL = `${BASE_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, member, tokenTime } = res.data.data;
localStorage.setItem("token", token);
localStorage.setItem("user", JSON.stringify(member));
localStorage.setItem("tokenTime", tokenTime);
setToken(token);
setUser(member);
setAuthStatus("authenticated");
} catch (err) {
console.error("Error al iniciar sesión:", err);
let message = "Ha ocurrido un error inesperado.";
if (err.response) {
const { status, data } = err.response;
if (status === 400) {
message = "Usuario o contraseña incorrectos.";
} else if (status === 403) {
message = "Tu cuenta está inactiva o ha sido suspendida.";
} else if (status === 404) {
message = "Usuario no encontrado.";
} else if (data?.message) {
message = data.message;
}
}
setError(message);
throw new Error(message);
}
};
const logout = () => {
localStorage.clear();
setUser(null);
setToken(null);
setAuthStatus("unauthenticated");
};
return (
<AuthContext.Provider value={{ user, token, authStatus, login, logout, error }}>
{children}
</AuthContext.Provider>
);
};

View File

@@ -9,22 +9,36 @@ export const ConfigProvider = ({ children }) => {
const [configError, setError] = useState(null); const [configError, setError] = useState(null);
useEffect(() => { useEffect(() => {
let isMounted = true;
const fetchConfig = async () => { const fetchConfig = async () => {
try { try {
const response = import.meta.env.MODE === 'production' const settingsPath = import.meta.env.MODE === 'production'
? await fetch("/config/settings.prod.json") ? "/config/settings.prod.json"
: await fetch("/config/settings.dev.json"); : "/config/settings.dev.json";
const response = await fetch(settingsPath);
if (!response.ok) throw new Error("Error al cargar settings.*.json"); if (!response.ok) throw new Error("Error al cargar settings.*.json");
const json = await response.json(); const json = await response.json();
if (isMounted) {
setConfig(json); setConfig(json);
}
} catch (err) { } catch (err) {
setError(err.message); if (isMounted) {
setError(err.message || "Error al cargar configuración");
}
} finally { } finally {
if (isMounted) {
setLoading(false); setLoading(false);
} }
}
}; };
fetchConfig(); fetchConfig();
return () => {
isMounted = false;
};
}, []); }, []);
return ( return (

View File

@@ -1,14 +1,18 @@
import { createContext } from "react"; import { createContext } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useData } from "../hooks/useData"; import { useData } from "../hooks/useData";
import { useError } from "./ErrorContext";
export const DataContext = createContext(); export const DataContext = createContext();
export const DataProvider = ({ config, children }) => { export const DataProvider = ({ config, children }) => {
const data = useData(config); const { showError } = useError();
// 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 ( return (
<DataContext.Provider value={data}> <DataContext.Provider value={dataApi}>
{children} {children}
</DataContext.Provider> </DataContext.Provider>
); );

View File

@@ -0,0 +1,35 @@
import { createContext, useState, useContext, useCallback } from 'react';
import NotificationModal from '../components/NotificationModal';
const ErrorContext = createContext();
export const ErrorProvider = ({ children }) => {
const [error, setError] = useState(null);
const showError = useCallback((err) => {
if (err.status === 422) return;
setError({
title: err.status ? `Error ${err.status}` : "Ups!",
message: err.message || "Algo ha salido mal miarma",
});
}, []);
const closeError = () => setError(null);
return (
<ErrorContext.Provider value={{ showError }}>
{children}
<NotificationModal
show={error !== null}
onClose={closeError}
title={error?.title || "Error"}
message={error?.message || ""}
variant='danger'
buttons={[{ label: "Entendido", variant: "danger", onClick: closeError }]}
/>
</ErrorContext.Provider>
);
};
export const useError = () => useContext(ErrorContext);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,3 +63,21 @@ hr {
} }
} }
@keyframes pulse-green {
0% {
transform: scale(0.95);
filter: drop-shadow(0 0 0px rgba(40, 167, 69, 0.7));
}
70% {
transform: scale(1);
filter: drop-shadow(0 0 6px rgba(40, 167, 69, 0.4));
}
100% {
transform: scale(0.95);
filter: drop-shadow(0 0 0px rgba(40, 167, 69, 0));
}
}
.pulse-animation {
animation: pulse-green 2s infinite;
}

View File

@@ -221,11 +221,11 @@ html, body, #root {
} }
/* Tipografía global */ /* Tipografía global */
div, div:not(.monaco-editor *),
span:not(.monaco-editor *),
label, label,
input, input,
p, p,
span,
a, a,
button { button {
font-family: "Open Sans", sans-serif; font-family: "Open Sans", sans-serif;

View File

@@ -1,4 +1,12 @@
import { useContext } from "react"; import { useContext } from "react";
import { AuthContext } from "@/context/AuthContext"; 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;
};

View File

@@ -1,4 +1,12 @@
import { useContext } from "react"; import { useContext } from "react";
import { ConfigContext } from "@/context/ConfigContext.jsx"; 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;
};

View File

@@ -1,133 +1,150 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback } from "react";
import axios from "axios"; import axios from "axios";
export const useData = (config) => { export const useData = (config, onError) => {
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [dataLoading, setLoading] = useState(true); const [dataLoading, setLoading] = useState(true);
const [dataError, setError] = useState(null); const [dataError, setError] = useState(null);
const configRef = useRef();
useEffect(() => { const getAuthHeaders = (isFormData = false) => {
if (config?.baseUrl) { const token = localStorage.getItem("token");
configRef.current = config; const headers = {};
} if (token) headers.Authorization = `Bearer ${token}`;
}, [config]); if (!isFormData) headers["Content-Type"] = "application/json";
return headers;
};
const getAuthHeaders = () => ({ const handleAxiosError = (err) => {
"Content-Type": "application/json", return {
"Authorization": `Bearer ${localStorage.getItem("token")}`, 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
};
};
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 () => { const fetchData = useCallback(async () => {
const current = configRef.current; if (!config?.baseUrl) return;
if (!current?.baseUrl) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await axios.get(current.baseUrl, { const response = await axios.get(config.baseUrl, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
params: current.params, params: config.params,
}); });
setData(response.data.data); setData(response.data);
} catch (err) { } catch (err) {
setError(err.response?.data?.message || err.message); const error = handleAxiosError(err);
if (!isExpectedPasteLookupError(config.baseUrl, error.status) && onError) onError(error);
setError(error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [config?.baseUrl, config?.params, onError]);
useEffect(() => { useEffect(() => {
if (config?.baseUrl) {
fetchData(); fetchData();
} }, [fetchData]);
}, [config, fetchData]);
const getData = async (url, params = {}, headers = {}) => { // 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 { try {
const response = await axios.get(url, { const isFormData = payload instanceof FormData;
headers: { ...getAuthHeaders(), ...headers },
params, const combinedHeaders = {
...getAuthHeaders(isFormData),
...extraHeaders
};
const axiosConfig = {
headers: combinedHeaders
};
let response;
if (method === "get") {
response = await axios.get(endpoint, {
...axiosConfig,
params: payload
}); });
return { data: response.data.data, error: null }; } else if (method === "delete") {
response = await axios.delete(endpoint, {
...axiosConfig,
data: payload
});
} else {
response = await axios[method](endpoint, payload, axiosConfig);
}
if (refresh) await fetchData();
return response.data;
} catch (err) { } catch (err) {
const error = handleAxiosError(err);
if (!silent && error.status !== 422 && onError) {
onError(error);
}
throw error;
}
};
return { return {
data: null, data, dataLoading, dataError,
error: { getData: (url, paramsOrOptions, refresh = true, h = {}, silent = false) => {
status: err.response?.data?.status || err.response?.status, const isOptionsObject =
message: err.response?.data?.message || err.message, 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,
const postData = async (endpoint, payload) => { }),
const headers = { putData: (url, body, refresh = true, silent = false) => requestWrapper("put", url, {
Authorization: `Bearer ${localStorage.getItem("token")}`, payload: body,
...(payload instanceof FormData ? {} : { "Content-Type": "application/json" }), refresh,
}; silent,
const response = await axios.post(endpoint, payload, { headers }); }),
await fetchData(); deleteData: (url, refresh = true, silent = false) => requestWrapper("delete", url, {
return response.data.data; refresh,
}; silent,
}),
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,
}; };
}; };

View File

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

View File

@@ -5,7 +5,6 @@ import { createRoot } from 'react-dom/client'
import App from '@/App.jsx' import App from '@/App.jsx'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider } from '@/context/ThemeContext' import { ThemeProvider } from '@/context/ThemeContext'
import { AuthProvider } from '@/context/AuthContext'
import { ConfigProvider } from '@/context/ConfigContext.jsx' import { ConfigProvider } from '@/context/ConfigContext.jsx'
/* CSS */ /* CSS */
@@ -15,18 +14,19 @@ import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css"; import "slick-carousel/slick/slick-theme.css";
import '@/css/index.css' import '@/css/index.css'
import { SearchProvider } from './context/SearchContext' import { SearchProvider } from './context/SearchContext'
import { ErrorProvider } from './context/ErrorContext'
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<ConfigProvider> <ConfigProvider>
<ThemeProvider> <ThemeProvider>
<AuthProvider> <ErrorProvider>
<BrowserRouter> <BrowserRouter>
<SearchProvider> <SearchProvider>
<App /> <App />
</SearchProvider> </SearchProvider>
</BrowserRouter> </BrowserRouter>
</AuthProvider> </ErrorProvider>
</ThemeProvider> </ThemeProvider>
</ConfigProvider> </ConfigProvider>
</StrictMode> </StrictMode>

View File

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

View File

@@ -3,75 +3,98 @@ import PastePanel from '@/components/Pastes/PastePanel';
import { useConfig } from '@/hooks/useConfig'; import { useConfig } from '@/hooks/useConfig';
import LoadingIcon from '@/components/LoadingIcon'; import LoadingIcon from '@/components/LoadingIcon';
import { useDataContext } from '@/hooks/useDataContext'; import { useDataContext } from '@/hooks/useDataContext';
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { DataProvider } from '@/context/DataContext'; import { DataProvider } from '@/context/DataContext';
import NotificationModal from '@/components/NotificationModal'; import NotificationModal from '@/components/NotificationModal';
import { useSearch } from "@/context/SearchContext"; import { useSearch } from "@/context/SearchContext";
import { useLocation, useParams } from 'react-router-dom';
const Home = () => { const Home = ({ mode, onConnectChange }) => {
const { pasteKey, rtKey } = useParams();
const { config, configLoading } = useConfig(); const { config, configLoading } = useConfig();
const location = useLocation();
const isStaticMode = mode === 'static';
if (configLoading) return <p><LoadingIcon /></p>; const currentKey = isStaticMode ? pasteKey : rtKey;
const reqConfig = { const requestConfig = useMemo(() => {
baseUrl: `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.pastes.all}`, if (!config?.apiConfig?.baseUrl) return null;
params: {
_sort: 'created_at', const baseApi = `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.pastes.all}`;
_order: 'desc',
}, return {
baseUrl: baseApi,
params: {}
}; };
}, [config]);
if (configLoading) return <p className="text-center mt-5"><LoadingIcon /></p>;
return ( return (
<DataProvider config={reqConfig}> <DataProvider key={location.key} config={requestConfig}>
<HomeContent reqConfig={reqConfig} /> <HomeContent requestConfig={requestConfig} mode={mode} pasteKey={currentKey} onConnectChange={onConnectChange} />
</DataProvider> </DataProvider>
); );
}; };
const HomeContent = ({ reqConfig }) => { const HomeContent = ({ requestConfig, mode, pasteKey, onConnectChange }) => {
const { data, dataLoading, dataError, postData } = useDataContext(); const { data, dataLoading, postData } = useDataContext();
const [error, setError] = useState(null); const [createdKey, setCreatedKey] = useState(null);
const [key, setKey] = useState(null);
const { searchTerm } = useSearch(); const { searchTerm } = useSearch();
const isStaticMode = mode === 'static';
if (dataLoading) return <p><LoadingIcon /></p>; const filteredPublicPastes = useMemo(() => {
if (dataError) return <p>Error loading data</p>; if (!Array.isArray(data)) return [];
const normalizedSearchTerm = (searchTerm ?? "").toLowerCase();
return data.filter((paste) => (paste.title ?? "").toLowerCase().includes(normalizedSearchTerm));
}, [data, searchTerm]);
const filtered = data.filter(paste => if (isStaticMode && dataLoading) return <p className="text-center mt-5"><LoadingIcon /></p>;
paste.title.toLowerCase().includes((searchTerm ?? "").toLowerCase()) ||
paste.content.toLowerCase().includes((searchTerm ?? "").toLowerCase())
);
const handleSubmit = async (paste) => { const handleSubmit = async (paste, isAutosave = false) => {
try { try {
const createdPaste = await postData(reqConfig.baseUrl, paste); const createdPaste = await postData(requestConfig.baseUrl, paste);
if (createdPaste && createdPaste.is_private) { if (!isAutosave && createdPaste && !paste.pasteKey) {
setKey(createdPaste.paste_key); setCreatedKey(createdPaste.pasteKey);
} }
} catch (error) { } catch (error) {
setError(error); console.error("Error:", error);
} }
}; };
return ( return (
<> <>
<PastePanel onSubmit={handleSubmit} publicPastes={filtered} /> <PastePanel
onSubmit={handleSubmit}
publicPastes={filteredPublicPastes}
mode={mode}
pasteKey={pasteKey}
onConnectChange={onConnectChange}
/>
<NotificationModal <NotificationModal
show={key !== null} show={createdKey !== null}
onClose={() => setKey(null)} onClose={() => setCreatedKey(null)}
title="Link a tu paste privado" title="¡Bomba! Paste creado"
message={ message={
<span> <span>
Tu paste privado ha sido creado. Puedes acceder a él mediante el siguiente enlace: Tu paste se ha guardado correctamente. Puedes compartirlo con este enlace:
<br /><br /> <br /><br />
<a href={`https://paste.miarma.net/${key}`}>https://paste.miarma.net/${key}</a> <a
href={`https://paste.miarma.net/s/${createdKey}`}
target="_blank"
rel="noreferrer"
className="text-primary font-weight-bold"
>
https://paste.miarma.net/s/{createdKey}
</a>
<br /><br /> <br /><br />
Recuerda que este enlace es único y no se puede recuperar si se pierde. {mode === 'rt' && "Nota: Al guardarlo, se ha creado una copia estática permanente."}
</span> </span>
} }
variant="" variant="success"
buttons={[ buttons={[
{ label: "Cerrar", variant: "secondary", onClick: () => setKey(null) } { label: "Cerrar", variant: "secondary", onClick: () => setCreatedKey(null) }
]} ]}
/> />
</> </>

View File

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

View File

@@ -1,7 +0,0 @@
'use strict';
const CONSTANTS = {
};
export { CONSTANTS };

View File

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

View File

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

View File

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

View File

@@ -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('');
};

View File

@@ -1,7 +0,0 @@
export const parseJwt = (token) => {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
return null;
}
};

View File

@@ -14,4 +14,22 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
}, },
define: {
global: 'window'
},
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('monaco-editor')) {
return 'monaco';
}
return 'vendor';
}
}
}
},
chunkSizeWarningLimit: 1500,
}
}) })