From 924f9626a657818144fcb3592f6928ce1f606418 Mon Sep 17 00:00:00 2001 From: Jose Date: Tue, 17 Mar 2026 02:25:59 +0100 Subject: [PATCH] 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. --- index.html | 25 ++-- package-lock.json | 137 +++++++++++++++++++++- package.json | 4 +- public/config/settings.dev.json | 4 +- public/config/settings.prod.json | 4 +- src/App.jsx | 43 ++++++- src/components/Auth/PasswordInput.jsx | 3 +- src/components/NavBar.jsx | 23 +++- src/components/Pastes/PastePanel.jsx | 162 +++++++++++++++++++++----- src/context/DataContext.jsx | 6 +- src/context/ErrorContext.jsx | 33 +++--- src/css/NavBar.css | 18 +++ src/hooks/useData.js | 129 +++++++------------- src/pages/Home.jsx | 90 +++++++++----- vite.config.js | 3 + 15 files changed, 499 insertions(+), 185 deletions(-) diff --git a/index.html b/index.html index 3b0059e..13c1fb5 100644 --- a/index.html +++ b/index.html @@ -1,13 +1,16 @@ - - - - - mpaste - - -
- - - + + + + + + mpaste + + + +
+ + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 19cbbee..de0191a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@fortawesome/free-solid-svg-icons": "^7.0.0", "@fortawesome/react-fontawesome": "^0.2.3", "@monaco-editor/react": "^4.7.0", + "@stomp/stompjs": "^7.3.0", "axios": "^1.11.0", "bootstrap": "^5.3.7", "date-fns": "^4.1.0", @@ -25,7 +26,8 @@ "react-dom": "^19.1.0", "react-router-dom": "^7.7.1", "react-slick": "^0.30.3", - "slick-carousel": "^1.8.1" + "slick-carousel": "^1.8.1", + "sockjs-client": "^1.6.1" }, "devDependencies": { "@eslint/js": "^9.30.1", @@ -1606,6 +1608,12 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@stomp/stompjs": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", + "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", + "license": "Apache-2.0" + }, "node_modules/@swc/helpers": { "version": "0.5.19", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz", @@ -2696,6 +2704,15 @@ "node": ">=0.10.0" } }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -2738,6 +2755,18 @@ "dev": true, "license": "MIT" }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -3174,6 +3203,12 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -3225,6 +3260,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -3544,7 +3585,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -3843,6 +3883,12 @@ "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -4001,6 +4047,12 @@ "react-dom": ">=16.6.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -4101,6 +4153,26 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4180,6 +4252,34 @@ "jquery": ">=1.8.0" } }, + "node_modules/sockjs-client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz", + "integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "eventsource": "^2.0.2", + "faye-websocket": "^0.11.4", + "inherits": "^2.0.4", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://tidelift.com/funding/github/npm/sockjs-client" + } + }, + "node_modules/sockjs-client/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4372,6 +4472,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -4456,6 +4566,29 @@ "loose-envify": "^1.0.0" } }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 171e057..9180d9d 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@fortawesome/free-solid-svg-icons": "^7.0.0", "@fortawesome/react-fontawesome": "^0.2.3", "@monaco-editor/react": "^4.7.0", + "@stomp/stompjs": "^7.3.0", "axios": "^1.11.0", "bootstrap": "^5.3.7", "date-fns": "^4.1.0", @@ -26,7 +27,8 @@ "react-dom": "^19.1.0", "react-router-dom": "^7.7.1", "react-slick": "^0.30.3", - "slick-carousel": "^1.8.1" + "slick-carousel": "^1.8.1", + "sockjs-client": "^1.6.1" }, "devDependencies": { "@eslint/js": "^9.30.1", diff --git a/public/config/settings.dev.json b/public/config/settings.dev.json index e1b9fc7..8013222 100644 --- a/public/config/settings.dev.json +++ b/public/config/settings.dev.json @@ -4,8 +4,8 @@ "endpoints": { "pastes": { "all": "/pastes", - "byId": "/:pasteId", - "byKey": "/:pasteKey" + "byId": "/by-id/:pasteId", + "byKey": "/s/:pasteKey" } } } diff --git a/public/config/settings.prod.json b/public/config/settings.prod.json index 23aadef..8810d2d 100644 --- a/public/config/settings.prod.json +++ b/public/config/settings.prod.json @@ -4,8 +4,8 @@ "endpoints": { "pastes": { "all": "/pastes", - "byId": "/:pasteId", - "byKey": "/:pasteKey" + "byId": "/by-id/:pasteId", + "byKey": "/s/:pasteKey" } } } diff --git a/src/App.jsx b/src/App.jsx index 2f8741e..7e44fb6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,17 +1,54 @@ import NavBar from '@/components/NavBar.jsx'; import { Route, Routes, useLocation } from 'react-router-dom' import Home from '@/pages/Home.jsx' +import { useState, useEffect } from 'react'; +import NotificationModal from './components/NotificationModal'; 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 ( <> - +
- } /> - } /> + } /> + } /> + } />
+ + He añadido un modo tiempo real pero de momento es EXPERIMENTAL. Cualquier fallo por favor mandadlo a jose [arroba] miarma.net. + + } + variant="warning" + buttons={[ + { + label: "Vale", + variant: "warning", + onClick: handleCloseWarning + } + ]} + /> ) } diff --git a/src/components/Auth/PasswordInput.jsx b/src/components/Auth/PasswordInput.jsx index 9fa1897..d46d68d 100644 --- a/src/components/Auth/PasswordInput.jsx +++ b/src/components/Auth/PasswordInput.jsx @@ -5,7 +5,7 @@ import '@/css/PasswordInput.css'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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 toggleShow = () => setShow(prev => !prev); @@ -28,6 +28,7 @@ const PasswordInput = ({ value, onChange, name = "password" }) => { placeholder="" onChange={onChange} className="rounded-4 pe-5" + disabled={disabled} /> diff --git a/src/components/NavBar.jsx b/src/components/NavBar.jsx index 913c703..0230374 100644 --- a/src/components/NavBar.jsx +++ b/src/components/NavBar.jsx @@ -6,8 +6,10 @@ import { Navbar, Nav, Container } from 'react-bootstrap'; import SearchToolbar from './SearchToolbar'; import { useSearch } from "@/context/SearchContext"; 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 [isLg, setIsLg] = useState(window.innerWidth >= 992); const [isXs, setIsXs] = useState(window.innerWidth < 576); @@ -47,7 +49,6 @@ const NavBar = () => { className='shadow-none custom-border-bottom' > - {/* brand */} { - {/* ThemeButton SIEMPRE fijo */}
- {/* burger */} { - {/* links y search que colapsan */}
- {/* Contact Modal */} setShowContactModal(false)} diff --git a/src/components/Pastes/PastePanel.jsx b/src/components/Pastes/PastePanel.jsx index 778df2f..f2a5bb3 100644 --- a/src/components/Pastes/PastePanel.jsx +++ b/src/components/Pastes/PastePanel.jsx @@ -1,20 +1,31 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Form, Button, Row, Col, FloatingLabel, Alert } from "react-bootstrap"; import '@/css/PastePanel.css'; import PasswordInput from "@/components/Auth/PasswordInput"; 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 PublicPasteItem from "./PublicPasteItem"; import { useParams, useNavigate } from "react-router-dom"; import { useDataContext } from "@/hooks/useDataContext"; import PasswordModal from "@/components/Auth/PasswordModal.jsx"; +import { Client } from "@stomp/stompjs"; +import SockJS from 'sockjs-client'; -const PastePanel = ({ onSubmit, publicPastes }) => { - const { pasteKey } = useParams(); +const PastePanel = ({ onSubmit, publicPastes, mode, pasteKey: propKey, onConnectChange }) => { + const { pasteKey: urlPasteKey, rtKey } = useParams(); const navigate = useNavigate(); const { getData } = useDataContext(); + const activeKey = propKey || urlPasteKey || rtKey; + + const [selectedPaste, setSelectedPaste] = useState(null); + const [editorErrors, setEditorErrors] = useState([]); + const [fieldErrors, setFieldErrors] = useState({}); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [stompClient, setStompClient] = useState(null); + const [connected, setConnected] = useState(null); + const [isSaving, setIsSaving] = useState(false); const [formData, setFormData] = useState({ title: "", content: "", @@ -24,10 +35,113 @@ const PastePanel = ({ onSubmit, publicPastes }) => { password: "" }); - const [selectedPaste, setSelectedPaste] = useState(null); - const [editorErrors, setEditorErrors] = useState([]); - const [fieldErrors, setFieldErrors] = useState({}); - const [showPasswordModal, setShowPasswordModal] = useState(false); + const lastSavedContent = useRef(formData.content); + + const isReadOnly = !!selectedPaste || mode === 'rt'; + const isRemoteChange = useRef(false); + + useEffect(() => { + if (mode === 'static' && activeKey) { + fetchPaste(activeKey); + } else if (mode === 'create') { + setSelectedPaste(null); + setFormData({ title: "", content: "", syntax: "", burnAfter: false, isPrivate: false, password: "" }); + setFieldErrors({}); + setEditorErrors([]); + } + }, [activeKey, mode]); + + 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); + } + }); + + client.activate(); + setStompClient(client); + return () => client.deactivate(); + } else { + setConnected(false); + } + }, [mode, activeKey]); + + useEffect(() => { + if (mode === 'rt' && connected && formData.content) { + + if (isRemoteChange.current) { + lastSavedContent.current = formData.content; + isRemoteChange.current = false; + return; + } + + if (formData.content !== lastSavedContent.current) { + const timer = setTimeout(async () => { + setIsSaving(true); + try { + const dataToSave = { + ...formData, + pasteKey: activeKey, + title: mode === 'rt' ? `Sesión: ${activeKey?.substring(0, 8)}` : 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]); + + const handleChange = (key, value) => { + const updatedData = { ...formData, [key]: value }; + + setFormData(updatedData); + + if (connected && stompClient && activeKey) { + stompClient.publish({ + destination: `/app/edit/${activeKey}`, + body: JSON.stringify(updatedData) + }); + } + }; const handleSubmit = async (e) => { e.preventDefault(); @@ -53,17 +167,17 @@ const PastePanel = ({ onSubmit, publicPastes }) => { } }; - const handleSelectPaste = async (key) => navigate(`/${key}`); + const handleSelectPaste = (key) => navigate(`/s/${key}`); const fetchPaste = async (key, pwd = "") => { const url = import.meta.env.MODE === 'production' - ? `https://api.miarma.net/v2/mpaste/pastes/${key}` - : `http://localhost:8081/v2/mpaste/pastes/${key}`; + ? `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, null, false, headers); + const response = await getData(url, null, false, headers, true); if (response) { setSelectedPaste(response); @@ -91,12 +205,6 @@ const PastePanel = ({ onSubmit, publicPastes }) => { } }; - useEffect(() => { if (pasteKey) fetchPaste(pasteKey); }, [pasteKey]); - - const handleChange = (key, value) => { - setFormData(prev => ({ ...prev, [key]: value })); - }; - return ( <>
@@ -134,20 +242,20 @@ const PastePanel = ({ onSubmit, publicPastes }) => { -
+
+ Título } > handleChange("title", e.target.value)} isInvalid={!!fieldErrors.title} /> @@ -202,7 +310,7 @@ const PastePanel = ({ onSubmit, publicPastes }) => { { { /> {formData.isPrivate && ( - handleChange("password", e.target.value)} /> + handleChange("password", e.target.value)} /> )}
@@ -243,7 +351,7 @@ const PastePanel = ({ onSubmit, publicPastes }) => { onClose={() => setShowPasswordModal(false)} onSubmit={(pwd) => { setShowPasswordModal(false); - fetchPaste(pasteKey, pwd); + fetchPaste(activeKey, pwd); }} /> diff --git a/src/context/DataContext.jsx b/src/context/DataContext.jsx index 7887f90..1bd5334 100644 --- a/src/context/DataContext.jsx +++ b/src/context/DataContext.jsx @@ -1,11 +1,13 @@ import { createContext } from "react"; import PropTypes from "prop-types"; import { useData } from "../hooks/useData"; +import { useError } from "./ErrorContext"; export const DataContext = createContext(); -export const DataProvider = ({ config, onError, children }) => { - const data = useData(config, onError); +export const DataProvider = ({ config, children }) => { + const { showError } = useError(); + const data = useData(config, showError); return ( diff --git a/src/context/ErrorContext.jsx b/src/context/ErrorContext.jsx index 4c0ff33..a7b0881 100644 --- a/src/context/ErrorContext.jsx +++ b/src/context/ErrorContext.jsx @@ -1,4 +1,4 @@ -import { createContext, useState, useContext } from 'react'; +import { createContext, useState, useContext, useCallback } from 'react'; import NotificationModal from '../components/NotificationModal'; const ErrorContext = createContext(); @@ -6,31 +6,30 @@ const ErrorContext = createContext(); export const ErrorProvider = ({ children }) => { const [error, setError] = useState(null); - const showError = (err) => { + const showError = useCallback((err) => { + if (err.status === 422) return; + setError({ - title: err.status ? `Error ${err.status}` : "Error", - message: err.message, - variant: 'danger' + title: err.status ? `Error ${err.status}` : "Ups!", + message: err.message || "Algo ha salido mal miarma", }); - }; + }, []); const closeError = () => setError(null); return ( {children} - {error && ( - - )} + ); }; -export const useError = () => useContext(ErrorContext); +export const useError = () => useContext(ErrorContext); \ No newline at end of file diff --git a/src/css/NavBar.css b/src/css/NavBar.css index 7379d5e..e281c14 100644 --- a/src/css/NavBar.css +++ b/src/css/NavBar.css @@ -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; +} \ No newline at end of file diff --git a/src/hooks/useData.js b/src/hooks/useData.js index 2133914..193fb20 100644 --- a/src/hooks/useData.js +++ b/src/hooks/useData.js @@ -5,141 +5,102 @@ export const useData = (config, onError) => { const [data, setData] = useState(null); const [dataLoading, setLoading] = useState(true); const [dataError, setError] = useState(null); - const configRef = useRef(); - useEffect(() => { - if (config?.baseUrl) { - configRef.current = config; - } - }, [config]); + const configString = JSON.stringify(config); const getAuthHeaders = (isFormData = false) => { const token = localStorage.getItem("token"); - const headers = {}; if (token) headers.Authorization = `Bearer ${token}`; - - if (!isFormData) { - headers["Content-Type"] = "application/json"; - } - + if (!isFormData) headers["Content-Type"] = "application/json"; return headers; }; const handleAxiosError = (err) => { - if (err.response && err.response.data) { - const data = err.response.data; - - if (data.status === 422 && data.errors) { - return { - status: 422, - errors: data.errors, - path: data.path ?? null, - timestamp: data.timestamp ?? null, - }; - } - - return { - status: data.status ?? err.response.status, - error: data.error ?? null, - message: data.message ?? err.response.statusText ?? "Error desconocido", - path: data.path ?? null, - timestamp: data.timestamp ?? null, - }; - } - - if (err.request) { - return { - status: null, - error: "Network Error", - message: "No se pudo conectar al servidor", - path: null, - timestamp: new Date().toISOString(), - }; - } - - return { - status: null, - error: "Client Error", - message: err.message || "Error desconocido", - path: null, - timestamp: new Date().toISOString(), + const errorData = { + 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 fetchData = useCallback(async () => { - const current = configRef.current; - if (!current?.baseUrl) return; + if (!config?.baseUrl) return; setLoading(true); setError(null); try { - const response = await axios.get(current.baseUrl, { + const response = await axios.get(config.baseUrl, { headers: getAuthHeaders(), - params: current.params, + params: config.params, }); setData(response.data); } catch (err) { const error = handleAxiosError(err); - setError(error); + 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); + } } finally { setLoading(false); } - }, []); + }, [configString, onError]); useEffect(() => { - if (config?.baseUrl) fetchData(); - }, [config, fetchData]); + fetchData(); + }, [fetchData]); - const requestWrapper = async (method, endpoint, payload = null, refresh = false, extraHeaders = {}) => { + const requestWrapper = async (method, endpoint, payload = null, refresh = false, extraHeaders = {}, silent = false) => { try { const isFormData = payload instanceof FormData; - const headers = { + const combinedHeaders = { ...getAuthHeaders(isFormData), ...extraHeaders }; - const cfg = { headers }; - let response; + const axiosConfig = { + headers: combinedHeaders + }; + let response; if (method === "get") { - if (payload) cfg.params = payload; - response = await axios.get(endpoint, cfg); + response = await axios.get(endpoint, { + ...axiosConfig, + params: payload + }); } else if (method === "delete") { - if (payload) cfg.data = payload; - response = await axios.delete(endpoint, cfg); + response = await axios.delete(endpoint, { + ...axiosConfig, + data: payload + }); } else { - response = await axios[method](endpoint, payload, cfg); + response = await axios[method](endpoint, payload, axiosConfig); } if (refresh) await fetchData(); return response.data; - } catch (err) { const error = handleAxiosError(err); - - if (error.status !== 403 && error.status !== 422) { - if (onError) onError(error); - setError(error); + if (!silent && error.status !== 422 && onError) { + onError(error); } - throw error; } }; - const clearError = () => setError(null); - return { - data, - dataLoading, - dataError, - clearError, - getData: (url, params, refresh = true, headers = {}) => requestWrapper("get", url, params, refresh, headers), - postData: (url, body, refresh = true) => requestWrapper("post", url, body, refresh), - putData: (url, body, refresh = true) => requestWrapper("put", url, body, refresh), - deleteData: (url, refresh = true) => requestWrapper("delete", url, null, refresh), - deleteDataWithBody: (url, body, refresh = true) => requestWrapper("delete", url, body, refresh) + 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), }; -}; +}; \ No newline at end of file diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index 8e5da30..3f681b5 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -3,72 +3,108 @@ import PastePanel from '@/components/Pastes/PastePanel'; import { useConfig } from '@/hooks/useConfig'; import LoadingIcon from '@/components/LoadingIcon'; import { useDataContext } from '@/hooks/useDataContext'; -import { useState } from 'react'; +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'; -const Home = () => { +const Home = ({ mode, onConnectChange }) => { + const { pasteKey, rtKey } = useParams(); const { config, configLoading } = useConfig(); const { showError } = useError(); + const location = useLocation(); - if (configLoading) return

; + const currentKey = mode === 'static' ? pasteKey : rtKey; - const reqConfig = { - baseUrl: `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.pastes.all}`, - params: {} - }; + const reqConfig = 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]); + + if (configLoading) return

; + + if (mode === 'static' && !reqConfig?.baseUrl?.includes('/s/')) { + return
; + } return ( - - + + ); }; -const HomeContent = ({ reqConfig }) => { - const { data, dataLoading, dataError, postData } = useDataContext(); - const [error, setError] = useState(null); - const [key, setKey] = useState(null); +const HomeContent = ({ reqConfig, mode, pasteKey, onConnectChange }) => { + const { data, dataLoading, postData } = useDataContext(); + const [createdKey, setCreatedKey] = useState(null); const { searchTerm } = useSearch(); - if (dataLoading) return

; + if (mode === 'static' && dataLoading) return

; const filtered = (data && Array.isArray(data)) ? data.filter(paste => paste.title.toLowerCase().includes((searchTerm ?? "").toLowerCase()) ) : []; - const handleSubmit = async (paste) => { + const handleSubmit = async (paste, isAutosave = false) => { try { const createdPaste = await postData(reqConfig.baseUrl, paste); - if (createdPaste && createdPaste.isPrivate) { - setKey(createdPaste.pasteKey); + if (!isAutosave && createdPaste && !paste.pasteKey) { + setCreatedKey(createdPaste.pasteKey); } } catch (error) { - setError(error); + console.error("Error:", error); } }; return ( <> - + + setKey(null)} - title="Link a tu paste privado" + show={createdKey !== null} + onClose={() => setCreatedKey(null)} + title="¡Bomba! Paste creado" message={ - 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:

- https://paste.miarma.net/{key} + + https://paste.miarma.net/s/{createdKey} +

- 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."}
} - variant="" + variant="success" buttons={[ - { label: "Cerrar", variant: "secondary", onClick: () => setKey(null) } + { label: "Cerrar", variant: "secondary", onClick: () => setCreatedKey(null) } ]} /> diff --git a/vite.config.js b/vite.config.js index a8506ad..3617408 100644 --- a/vite.config.js +++ b/vite.config.js @@ -14,4 +14,7 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + define: { + global: 'window' + } })