diff --git a/src/components/AnimatedDropdown.jsx b/src/components/AnimatedDropdown.jsx deleted file mode 100644 index c8c0e08..0000000 --- a/src/components/AnimatedDropdown.jsx +++ /dev/null @@ -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 })) - : ( - - ); - - const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`; - - return ( -
- {triggerElement} - - - {actualOpen && ( - <_motion.div - ref={dropdownRef} - initial={{ opacity: 0, scale: 0.98 }} - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.98 }} - transition={{ duration: 0.15 }} - className={dropdownClasses} - > - {typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children} - - )} - -
- ); -}; - -export default AnimatedDropdown; diff --git a/src/components/AnimatedDropend.jsx b/src/components/AnimatedDropend.jsx deleted file mode 100644 index 63e98e5..0000000 --- a/src/components/AnimatedDropend.jsx +++ /dev/null @@ -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 - })) - : ( - - ); - - const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`; - - return ( -
- {triggerElement} - - - {actualOpen && ( - <_motion.div - ref={dropdownRef} - initial={{ opacity: 0, x: -10 }} - animate={{ opacity: 1, x: 0 }} - exit={{ opacity: 0, x: -10 }} - transition={{ duration: 0.15 }} - className={dropdownClasses} - style={{ - position: 'absolute', - top: '0', - left: '100%', - zIndex: 1000, - whiteSpace: 'nowrap' - }} - > - {typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children} - - )} - -
- ); -}; - -export default AnimatedDropend; diff --git a/src/components/Auth/IfAuthenticated.jsx b/src/components/Auth/IfAuthenticated.jsx deleted file mode 100644 index 9504174..0000000 --- a/src/components/Auth/IfAuthenticated.jsx +++ /dev/null @@ -1,8 +0,0 @@ -import { useAuth } from "@/hooks/useAuth.js"; - -const IfAuthenticated = ({ children }) => { - const { authStatus } = useAuth(); - return authStatus === "authenticated" ? children : null; -}; - -export default IfAuthenticated; diff --git a/src/components/Auth/IfNotAuthenticated.jsx b/src/components/Auth/IfNotAuthenticated.jsx deleted file mode 100644 index 53593c1..0000000 --- a/src/components/Auth/IfNotAuthenticated.jsx +++ /dev/null @@ -1,8 +0,0 @@ -import { useAuth } from "@/hooks/useAuth.js"; - -const IfNotAuthenticated = ({ children }) => { - const { authStatus } = useAuth(); - return authStatus === "unauthenticated" ? children : null; -}; - -export default IfNotAuthenticated; diff --git a/src/components/Auth/IfRole.jsx b/src/components/Auth/IfRole.jsx deleted file mode 100644 index 336791a..0000000 --- a/src/components/Auth/IfRole.jsx +++ /dev/null @@ -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; diff --git a/src/components/Auth/LoginForm.jsx b/src/components/Auth/LoginForm.jsx deleted file mode 100644 index fd707cd..0000000 --- a/src/components/Auth/LoginForm.jsx +++ /dev/null @@ -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 ( - - -
-

Inicio de sesión

-
-
- - - Usuario o Email - - } - > - - - - - -
- { formState.keepLoggedIn = e.target.checked; setFormState({ ...formState }) }} - /> - {/* - Olvidé mi contraseña - */} -
-
- - {error && ( - - {error} - - )} - -
- -
-
-
-
-
- ); -}; - - -export default LoginForm; diff --git a/src/components/Auth/ProtectedRoute.jsx b/src/components/Auth/ProtectedRoute.jsx deleted file mode 100644 index 911d951..0000000 --- a/src/components/Auth/ProtectedRoute.jsx +++ /dev/null @@ -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 ; // o un loader si quieres - if (authStatus === "unauthenticated") return ; - if (authStatus === "authenticated" && minimumRoles) { - const userRole = JSON.parse(localStorage.getItem("user"))?.role; - if (!minimumRoles.includes(userRole)) return ; - } - return children; -}; - -export default ProtectedRoute; diff --git a/src/components/ContentWrapper.jsx b/src/components/ContentWrapper.jsx deleted file mode 100644 index 22312ea..0000000 --- a/src/components/ContentWrapper.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import PropTypes from 'prop-types'; - -const ContentWrapper = ({ children }) => { - return ( -
- {children} -
- ); -} - -ContentWrapper.propTypes = { - children: PropTypes.node.isRequired, -} - -export default ContentWrapper; \ No newline at end of file diff --git a/src/components/CustomCarousel.jsx b/src/components/CustomCarousel.jsx deleted file mode 100644 index 014e839..0000000 --- a/src/components/CustomCarousel.jsx +++ /dev/null @@ -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 ( -
- - {images.map((src, index) => ( -
- {`slide-${index}`} -
- ))} -
-
- ); -}; - -export default CustomCarousel; diff --git a/src/components/CustomContainer.jsx b/src/components/CustomContainer.jsx deleted file mode 100644 index 6d14ffb..0000000 --- a/src/components/CustomContainer.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import PropTypes from 'prop-types'; - -const CustomContainer = ({ children }) => { - return ( -
- {children} -
- ); -} - -CustomContainer.propTypes = { - children: PropTypes.node.isRequired, -} - -export default CustomContainer; \ No newline at end of file diff --git a/src/components/CustomModal.jsx b/src/components/CustomModal.jsx deleted file mode 100644 index 4ab5139..0000000 --- a/src/components/CustomModal.jsx +++ /dev/null @@ -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 ( - - - {title} - - - - {children} - - - ); -} - -export default CustomModal; \ No newline at end of file diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx deleted file mode 100644 index d05bc76..0000000 --- a/src/components/Footer.jsx +++ /dev/null @@ -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 ( - - ); -}; - -export default Footer; diff --git a/src/components/Header.jsx b/src/components/Header.jsx deleted file mode 100644 index 56e400a..0000000 --- a/src/components/Header.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import '@/css/Header.css'; -import { Link } from 'react-router-dom'; - -const Header = () => { - - return ( -
-
-
- -

Tu página web

- -
-
-
- ); -} - -export default Header; \ No newline at end of file diff --git a/src/components/Auth/PasswordInput.jsx b/src/components/Pastes/PasswordInput.jsx similarity index 100% rename from src/components/Auth/PasswordInput.jsx rename to src/components/Pastes/PasswordInput.jsx diff --git a/src/components/Auth/PasswordModal.jsx b/src/components/Pastes/PasswordModal.jsx similarity index 93% rename from src/components/Auth/PasswordModal.jsx rename to src/components/Pastes/PasswordModal.jsx index fd1a5b0..96b3572 100644 --- a/src/components/Auth/PasswordModal.jsx +++ b/src/components/Pastes/PasswordModal.jsx @@ -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.

- {renderErrorAlert(error)} - setPassword(e.target.value)} diff --git a/src/components/Pastes/PastePanel.jsx b/src/components/Pastes/PastePanel.jsx index bb3b001..e436ac8 100644 --- a/src/components/Pastes/PastePanel.jsx +++ b/src/components/Pastes/PastePanel.jsx @@ -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,30 @@ 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 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 +108,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,6 +144,7 @@ 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) => { const updatedData = { ...formData, [key]: value, isRt: mode === 'rt' }; @@ -150,8 +163,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 +192,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 +204,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) { @@ -311,7 +341,7 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect
- {isSaving ? ( + {connected && (isSaving ? ( Guardando cambios... @@ -320,7 +350,7 @@ const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnect Cambios guardados - )} + ))}
{ const PublicPasteItem = ({ paste, onSelect }) => { return (
onSelect(paste.pasteKey)}> -
{paste.title}
+
{(paste.title ?? "").trim() || "Sin título"}

{trimContent(paste.content, 100)}

{new Date(paste.createdAt).toLocaleString()} diff --git a/src/context/ConfigContext.jsx b/src/context/ConfigContext.jsx index 25b59bc..f85b62e 100644 --- a/src/context/ConfigContext.jsx +++ b/src/context/ConfigContext.jsx @@ -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(); - setConfig(json); + if (isMounted) { + setConfig(json); + } } catch (err) { - setError(err.message); + if (isMounted) { + setError(err.message || "Error al cargar configuración"); + } } finally { - setLoading(false); + if (isMounted) { + setLoading(false); + } } }; fetchConfig(); + + return () => { + isMounted = false; + }; }, []); return ( diff --git a/src/context/DataContext.jsx b/src/context/DataContext.jsx index 1bd5334..05cc4c5 100644 --- a/src/context/DataContext.jsx +++ b/src/context/DataContext.jsx @@ -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 ( - + {children} ); diff --git a/src/css/AnimatedDropdown.css b/src/css/AnimatedDropdown.css deleted file mode 100644 index ba10c6e..0000000 --- a/src/css/AnimatedDropdown.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/css/CustomCarousel.css b/src/css/CustomCarousel.css deleted file mode 100644 index 4e44fca..0000000 --- a/src/css/CustomCarousel.css +++ /dev/null @@ -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; -} diff --git a/src/css/Footer.css b/src/css/Footer.css deleted file mode 100644 index 95fd13e..0000000 --- a/src/css/Footer.css +++ /dev/null @@ -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); - } -} diff --git a/src/css/Header.css b/src/css/Header.css deleted file mode 100644 index 3d51fa8..0000000 --- a/src/css/Header.css +++ /dev/null @@ -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; - } -} diff --git a/src/css/LoginForm.css b/src/css/LoginForm.css deleted file mode 100644 index e6d933d..0000000 --- a/src/css/LoginForm.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/src/hooks/useAuth.js b/src/hooks/useAuth.js index ad9a9f7..86604bd 100644 --- a/src/hooks/useAuth.js +++ b/src/hooks/useAuth.js @@ -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; +}; diff --git a/src/hooks/useConfig.js b/src/hooks/useConfig.js index 024ee84..43e072a 100644 --- a/src/hooks/useConfig.js +++ b/src/hooks/useConfig.js @@ -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; +}; diff --git a/src/hooks/useData.js b/src/hooks/useData.js index 193fb20..dfd9cd3 100644 --- a/src/hooks/useData.js +++ b/src/hooks/useData.js @@ -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..."); - setError(error); - } else { - if (onError) onError(error); - setError(error); - } + if (!isExpectedPasteLookupError(config.baseUrl, error.status) && 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, + }), }; }; \ No newline at end of file diff --git a/src/hooks/useSessionRenewal.jsx b/src/hooks/useSessionRenewal.jsx deleted file mode 100644 index 4566619..0000000 --- a/src/hooks/useSessionRenewal.jsx +++ /dev/null @@ -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 && ( - { - setShowModal(false); - logout(); - }} - title="¿Quieres seguir conectado?" - message="Tu sesión está a punto de expirar. ¿Quieres renovarla 1 hora más?" - variant="info" - buttons={[ - { - label: "Renovar sesión", - variant: "success", - onClick: handleRenew, - }, - { - label: "Cerrar sesión", - variant: "danger", - onClick: () => { - logout(); - setShowModal(false); - }, - }, - ]} - /> - ); - - return { modal }; -}; - -export default useSessionRenewal; diff --git a/src/pages/Building.jsx b/src/pages/Building.jsx deleted file mode 100644 index 5a2c4dc..0000000 --- a/src/pages/Building.jsx +++ /dev/null @@ -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 ( -
-
🚧
-
Esta página está en construcción
-
Estamos trabajando para traértela pronto
-
- - ); -} diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index 3f681b5..db31b5b 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -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

; - if (mode === 'static' && !reqConfig?.baseUrl?.includes('/s/')) { - return
; - } - return ( - - + + ); }; -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

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

; 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 }) => { <> { - const { className = 'alert alert-danger alert-dismissible py-1 px-2 small', role = 'alert' } = options; - - if (!error) return null; - - return ( -
- {typeof error === 'string' ? error : 'An unexpected error occurred.'} -
- ); -}; - -export const resetErrorIfEditEnds = (editMode, setError) => { - if (!editMode) setError(null); -}; diff --git a/vite.config.js b/vite.config.js index 3617408..0460669 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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, } })