Compare commits
8 Commits
589215b2bc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a848b3f56 | ||
|
|
d9eb92300a | ||
|
|
bf40b235f0 | ||
|
|
fcd477e876 | ||
|
|
924f9626a6 | ||
|
|
f29d82f311 | ||
|
|
69140e6da1 | ||
|
|
4d0c4d3f26 |
23
index.html
23
index.html
@@ -1,13 +1,16 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||||
<title style="font-family: Fira Code;">mpaste</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
</head>
|
<title style="font-family: Fira Code;">mpaste</title>
|
||||||
<body>
|
</head>
|
||||||
<div id="root" class="p-0 m-0"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<body>
|
||||||
</body>
|
<div id="root" class="p-0 m-0"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
4657
package-lock.json
generated
Normal file
4657
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/App.jsx
43
src/App.jsx
@@ -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
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { useState, useRef, useEffect, cloneElement } from 'react';
|
|
||||||
import { Button } from 'react-bootstrap';
|
|
||||||
import { AnimatePresence, motion as _motion } from 'framer-motion';
|
|
||||||
import '../css/AnimatedDropdown.css';
|
|
||||||
|
|
||||||
const AnimatedDropdown = ({
|
|
||||||
trigger,
|
|
||||||
icon,
|
|
||||||
variant = "secondary",
|
|
||||||
className = "",
|
|
||||||
buttonStyle = "",
|
|
||||||
show,
|
|
||||||
onToggle,
|
|
||||||
onMouseEnter,
|
|
||||||
onMouseLeave,
|
|
||||||
children
|
|
||||||
}) => {
|
|
||||||
const isControlled = show !== undefined;
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const triggerRef = useRef(null);
|
|
||||||
const dropdownRef = useRef(null);
|
|
||||||
|
|
||||||
const actualOpen = isControlled ? show : open;
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
const newState = !actualOpen;
|
|
||||||
if (!isControlled) setOpen(newState);
|
|
||||||
onToggle?.(newState);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (e) => {
|
|
||||||
if (
|
|
||||||
dropdownRef.current &&
|
|
||||||
!dropdownRef.current.contains(e.target) &&
|
|
||||||
!triggerRef.current?.contains(e.target)
|
|
||||||
) {
|
|
||||||
if (!isControlled) setOpen(false);
|
|
||||||
onToggle?.(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [isControlled, onToggle]);
|
|
||||||
|
|
||||||
const triggerElement = trigger
|
|
||||||
? (typeof trigger === "function"
|
|
||||||
? trigger({ onClick: toggle, ref: triggerRef })
|
|
||||||
: cloneElement(trigger, { onClick: toggle, ref: triggerRef }))
|
|
||||||
: (
|
|
||||||
<Button
|
|
||||||
ref={triggerRef}
|
|
||||||
variant={variant}
|
|
||||||
className={`circle-btn ${buttonStyle}`}
|
|
||||||
onClick={toggle}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`position-relative d-inline-block`}
|
|
||||||
onClick={toggle}
|
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
ref={triggerRef}
|
|
||||||
>
|
|
||||||
{triggerElement}
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{actualOpen && (
|
|
||||||
<_motion.div
|
|
||||||
ref={dropdownRef}
|
|
||||||
initial={{ opacity: 0, scale: 0.98 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.98 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className={dropdownClasses}
|
|
||||||
>
|
|
||||||
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
|
|
||||||
</_motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AnimatedDropdown;
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { useState, useRef, useEffect, cloneElement } from 'react';
|
|
||||||
import { Button } from 'react-bootstrap';
|
|
||||||
import { AnimatePresence, motion as _motion } from 'framer-motion';
|
|
||||||
import '@/css/AnimatedDropdown.css';
|
|
||||||
|
|
||||||
const AnimatedDropend = ({
|
|
||||||
trigger,
|
|
||||||
icon,
|
|
||||||
variant = "secondary",
|
|
||||||
className = "",
|
|
||||||
buttonStyle = "",
|
|
||||||
show,
|
|
||||||
onToggle,
|
|
||||||
onMouseEnter,
|
|
||||||
onMouseLeave,
|
|
||||||
children
|
|
||||||
}) => {
|
|
||||||
const isControlled = show !== undefined;
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const triggerRef = useRef(null);
|
|
||||||
const dropdownRef = useRef(null);
|
|
||||||
|
|
||||||
const actualOpen = isControlled ? show : open;
|
|
||||||
|
|
||||||
const toggle = (forceValue) => {
|
|
||||||
const newState = typeof forceValue === "boolean" ? forceValue : !actualOpen;
|
|
||||||
if (!isControlled) setOpen(newState);
|
|
||||||
onToggle?.(newState);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (e) => {
|
|
||||||
if (
|
|
||||||
dropdownRef.current &&
|
|
||||||
!dropdownRef.current.contains(e.target) &&
|
|
||||||
!triggerRef.current?.contains(e.target)
|
|
||||||
) {
|
|
||||||
if (!isControlled) setOpen(false);
|
|
||||||
onToggle?.(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, [isControlled, onToggle]);
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
if (!isControlled) setOpen(true);
|
|
||||||
onToggle?.(true);
|
|
||||||
onMouseEnter?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
if (!isControlled) setOpen(false);
|
|
||||||
onToggle?.(false);
|
|
||||||
onMouseLeave?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const triggerElement = trigger
|
|
||||||
? (typeof trigger === "function"
|
|
||||||
? trigger({
|
|
||||||
onClick: e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggle();
|
|
||||||
},
|
|
||||||
ref: triggerRef
|
|
||||||
})
|
|
||||||
: cloneElement(trigger, {
|
|
||||||
onClick: e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggle();
|
|
||||||
},
|
|
||||||
ref: triggerRef
|
|
||||||
}))
|
|
||||||
: (
|
|
||||||
<Button
|
|
||||||
ref={triggerRef}
|
|
||||||
variant={variant}
|
|
||||||
className={`circle-btn ${buttonStyle}`}
|
|
||||||
onClick={toggle}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="position-relative d-inline-block dropend"
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
ref={triggerRef}
|
|
||||||
>
|
|
||||||
{triggerElement}
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{actualOpen && (
|
|
||||||
<_motion.div
|
|
||||||
ref={dropdownRef}
|
|
||||||
initial={{ opacity: 0, x: -10 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -10 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className={dropdownClasses}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '0',
|
|
||||||
left: '100%',
|
|
||||||
zIndex: 1000,
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
|
|
||||||
</_motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AnimatedDropend;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { useAuth } from "@/hooks/useAuth.js";
|
|
||||||
|
|
||||||
const IfAuthenticated = ({ children }) => {
|
|
||||||
const { authStatus } = useAuth();
|
|
||||||
return authStatus === "authenticated" ? children : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IfAuthenticated;
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { useAuth } from "@/hooks/useAuth.js";
|
|
||||||
|
|
||||||
const IfNotAuthenticated = ({ children }) => {
|
|
||||||
const { authStatus } = useAuth();
|
|
||||||
return authStatus === "unauthenticated" ? children : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IfNotAuthenticated;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { useAuth } from "@/hooks/useAuth.js";
|
|
||||||
|
|
||||||
const IfRole = ({ roles, children }) => {
|
|
||||||
const { user, authStatus } = useAuth();
|
|
||||||
|
|
||||||
if (authStatus !== "authenticated") return null;
|
|
||||||
|
|
||||||
const userRole = user?.role;
|
|
||||||
|
|
||||||
return roles.includes(userRole) ? children : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IfRole;
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faUser } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { Form, Button, Alert, FloatingLabel, Row, Col } from 'react-bootstrap';
|
|
||||||
import PasswordInput from '@/components/Auth/PasswordInput.jsx';
|
|
||||||
|
|
||||||
import { useContext, useState } from "react";
|
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
|
||||||
import { AuthContext } from "@/context/AuthContext.jsx";
|
|
||||||
|
|
||||||
import CustomContainer from '@/components/CustomContainer.jsx';
|
|
||||||
import ContentWrapper from '@/components/ContentWrapper.jsx';
|
|
||||||
|
|
||||||
import '@/css/LoginForm.css';
|
|
||||||
|
|
||||||
const LoginForm = () => {
|
|
||||||
const { login, error } = useContext(AuthContext);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const [formState, setFormState] = useState({
|
|
||||||
emailOrUserName: "",
|
|
||||||
password: "",
|
|
||||||
keepLoggedIn: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormState((prev) => ({ ...prev, [name]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formState.emailOrUserName);
|
|
||||||
|
|
||||||
const loginBody = {
|
|
||||||
password: formState.password,
|
|
||||||
keepLoggedIn: Boolean(formState.keepLoggedIn),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEmail) {
|
|
||||||
loginBody.email = formState.emailOrUserName;
|
|
||||||
} else {
|
|
||||||
loginBody.userName = formState.emailOrUserName;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await login(loginBody);
|
|
||||||
navigate("/");
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error de login:", err.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CustomContainer>
|
|
||||||
<ContentWrapper>
|
|
||||||
<div className="login-card card shadow p-5 rounded-5 mx-auto col-12 col-md-8 col-lg-6 col-xl-5 d-flex flex-column gap-4">
|
|
||||||
<h1 className="text-center">Inicio de sesión</h1>
|
|
||||||
<Form className="d-flex flex-column gap-5" onSubmit={handleSubmit}>
|
|
||||||
<div className="d-flex flex-column gap-3">
|
|
||||||
<FloatingLabel
|
|
||||||
controlId="floatingUsuario"
|
|
||||||
label={
|
|
||||||
<>
|
|
||||||
<FontAwesomeIcon icon={faUser} className="me-2" />
|
|
||||||
Usuario o Email
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Form.Control
|
|
||||||
type="text"
|
|
||||||
placeholder=""
|
|
||||||
name="emailOrUserName"
|
|
||||||
value={formState.emailOrUserName}
|
|
||||||
onChange={handleChange}
|
|
||||||
className="rounded-4"
|
|
||||||
/>
|
|
||||||
</FloatingLabel>
|
|
||||||
|
|
||||||
<PasswordInput
|
|
||||||
value={formState.password}
|
|
||||||
onChange={handleChange}
|
|
||||||
name="password"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center gap-2">
|
|
||||||
<Form.Check
|
|
||||||
type="checkbox"
|
|
||||||
name="keepLoggedIn"
|
|
||||||
label="Mantener sesión iniciada"
|
|
||||||
className="text-secondary"
|
|
||||||
value={formState.keepLoggedIn}
|
|
||||||
onChange={(e) => { formState.keepLoggedIn = e.target.checked; setFormState({ ...formState }) }}
|
|
||||||
/>
|
|
||||||
{/*<Link disabled to="#" className="muted">
|
|
||||||
Olvidé mi contraseña
|
|
||||||
</Link>*/}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="danger" className="text-center py-2 mb-0">
|
|
||||||
{error}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<Button type="submit" className="w-75 padding-4 rounded-4 border-0 shadow-sm login-button">
|
|
||||||
Iniciar sesión
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</ContentWrapper>
|
|
||||||
</CustomContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default LoginForm;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Navigate } from "react-router-dom";
|
|
||||||
import { useAuth } from "@/hooks/useAuth.js";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
|
|
||||||
const ProtectedRoute = ({ minimumRoles, children }) => {
|
|
||||||
const { authStatus } = useAuth();
|
|
||||||
|
|
||||||
if (authStatus === "checking") return <FontAwesomeIcon icon={faSpinner} />; // o un loader si quieres
|
|
||||||
if (authStatus === "unauthenticated") return <Navigate to="/login" replace />;
|
|
||||||
if (authStatus === "authenticated" && minimumRoles) {
|
|
||||||
const userRole = JSON.parse(localStorage.getItem("user"))?.role;
|
|
||||||
if (!minimumRoles.includes(userRole)) return <Navigate to="/" replace />;
|
|
||||||
}
|
|
||||||
return children;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProtectedRoute;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const ContentWrapper = ({ children }) => {
|
|
||||||
return (
|
|
||||||
<div className="container-xl">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ContentWrapper.propTypes = {
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ContentWrapper;
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import Slider from 'react-slick';
|
|
||||||
import '@/css/CustomCarousel.css';
|
|
||||||
|
|
||||||
const CustomCarousel = ({ images }) => {
|
|
||||||
const settings = {
|
|
||||||
dots: false,
|
|
||||||
infinite: true,
|
|
||||||
speed: 500,
|
|
||||||
slidesToShow: 2,
|
|
||||||
slidesToScroll: 1,
|
|
||||||
arrows: false,
|
|
||||||
autoplay: true,
|
|
||||||
autoplaySpeed: 3000,
|
|
||||||
responsive: [
|
|
||||||
{
|
|
||||||
breakpoint: 768, // móviles
|
|
||||||
settings: {
|
|
||||||
slidesToShow: 1,
|
|
||||||
arrows: false,
|
|
||||||
autoplay: true,
|
|
||||||
autoplaySpeed: 3000,
|
|
||||||
dots: false,
|
|
||||||
infinite: true,
|
|
||||||
speed: 500
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="my-4">
|
|
||||||
<Slider {...settings}>
|
|
||||||
{images.map((src, index) => (
|
|
||||||
<div key={index} className='carousel-img-wrapper'>
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={`slide-${index}`}
|
|
||||||
className="carousel-img"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Slider>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CustomCarousel;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const CustomContainer = ({ children }) => {
|
|
||||||
return (
|
|
||||||
<main className="px-4 py-5">
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CustomContainer.propTypes = {
|
|
||||||
children: PropTypes.node.isRequired,
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CustomContainer;
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { faXmark } from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
||||||
import { Modal, Button } from "react-bootstrap";
|
|
||||||
|
|
||||||
const CustomModal = ({ show, onClose, title, children }) => {
|
|
||||||
return (
|
|
||||||
<Modal show={show} onHide={onClose} size="xl" centered>
|
|
||||||
<Modal.Header className='justify-content-between rounded-top-4'>
|
|
||||||
<Modal.Title>{title}</Modal.Title>
|
|
||||||
<Button variant='transparent' onClick={onClose}>
|
|
||||||
<FontAwesomeIcon icon={faXmark} className='close-button fa-xl' />
|
|
||||||
</Button>
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body className="rounded-bottom-4 p-0"
|
|
||||||
style={{
|
|
||||||
maxHeight: '80vh',
|
|
||||||
overflowY: 'auto',
|
|
||||||
padding: '1rem',
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</Modal.Body>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CustomModal;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import '@/css/Footer.css';
|
|
||||||
import { faGithub } from '@fortawesome/free-brands-svg-icons';
|
|
||||||
|
|
||||||
const Footer = () => {
|
|
||||||
const [heart, setHeart] = useState('💜');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const hearts = ["❤️", "💛", "🧡", "💚", "💙", "💜"];
|
|
||||||
const randomHeart = () => hearts[Math.floor(Math.random() * hearts.length)];
|
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setHeart(randomHeart());
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer d-flex flex-column align-items-center gap-5 pt-5 px-4">
|
|
||||||
<div className="footer-columns w-100" style={{ maxWidth: '900px' }}>
|
|
||||||
<div className="footer-column">
|
|
||||||
<h4 className="footer-title">Contacto</h4>
|
|
||||||
<div className="contact-info p-4">
|
|
||||||
<a
|
|
||||||
href="https://github.com/Gallardo7761"
|
|
||||||
target="_blank"
|
|
||||||
className='text-break d-block'
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faGithub} className="fa-icon me-2 " />
|
|
||||||
Gallardo7761
|
|
||||||
</a>
|
|
||||||
<a href="mailto:jose@miarma.net" className="text-break d-block">
|
|
||||||
<FontAwesomeIcon icon={faEnvelope} className="fa-icon me-2" />
|
|
||||||
jose@miarma.net
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="footer-bottom w-100 py-5 text-center">
|
|
||||||
<h6 id="devd" className='m-0'>
|
|
||||||
Hecho con <span className="heart-anim">{heart}</span> por{' '}
|
|
||||||
<a href="https://gallardo.dev" target="_blank" rel="noopener noreferrer">
|
|
||||||
Gallardo7761
|
|
||||||
</a>
|
|
||||||
</h6>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Footer;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import '@/css/Header.css';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
const Header = () => {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className={`text-center bg-img`}>
|
|
||||||
<div className="m-0 p-5 mask">
|
|
||||||
<div className="d-flex flex-column justify-content-center align-items-center h-100">
|
|
||||||
<Link to='/' className='text-decoration-none'>
|
|
||||||
<h1 className='header-title m-0 text-white shadowed'>Tu página web</h1>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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)}
|
||||||
@@ -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]);
|
||||||
|
|
||||||
if (error) {
|
// Gestiona el ciclo de vida del WebSocket en tiempo real:
|
||||||
if (error?.status === 401) {
|
// conecta al entrar en modo rt y limpia la conexión al salir.
|
||||||
setShowPasswordModal(true);
|
// Los cambios remotos marcan `isRemoteChange` para no disparar autosave en bucle.
|
||||||
return;
|
useEffect(() => {
|
||||||
} else {
|
if (mode === 'rt' && activeKey) {
|
||||||
setError(error);
|
const socketUrl = import.meta.env.MODE === 'production'
|
||||||
setSelectedPaste(null);
|
? `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]);
|
||||||
|
|
||||||
|
// 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;
|
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);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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();
|
||||||
setConfig(json);
|
if (isMounted) {
|
||||||
|
setConfig(json);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
if (isMounted) {
|
||||||
|
setError(err.message || "Error al cargar configuración");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
35
src/context/ErrorContext.jsx
Normal file
35
src/context/ErrorContext.jsx
Normal 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);
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
.dropdown-menu .dropdown-divider {
|
|
||||||
border-top: 1px solid var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu {
|
|
||||||
background-color: var(--bg-color) !important;
|
|
||||||
color: var(--text-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu.show {
|
|
||||||
background-color: var(--navbar-bg) !important;
|
|
||||||
box-shadow: 0 5px 10px var(--shadow-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
background-color: var(--navbar-bg) !important;
|
|
||||||
color: var(--navbar-dropdown-item-color);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:hover {
|
|
||||||
background-color: var(--navbar-bg) !important;
|
|
||||||
color: var(--secondary-color) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disabled.text-muted {
|
|
||||||
color: var(--muted-color) !important;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
.carousel-img-wrapper {
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-img {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 1rem;
|
|
||||||
max-height: 60vh;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
.footer {
|
|
||||||
background-color: var(--navbar-bg);
|
|
||||||
color: var(--fg-color);
|
|
||||||
border-top: 2px solid var(--border-color);
|
|
||||||
font-size: 1rem;
|
|
||||||
box-shadow: 0 -2px 8px var(--shadow-color);
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-title,
|
|
||||||
.footer h6#devd {
|
|
||||||
font-family: "Product Sans";
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-columns {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
background-color: var(--bg-hover-color);
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
box-shadow: 0 4px 10px rgba(0,0,0,0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.footer-columns {
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-column {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-column h5 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--fg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-column ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-column ul li {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-column ul li a {
|
|
||||||
color: var(--fg-color);
|
|
||||||
text-decoration: none;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-column ul li a:hover {
|
|
||||||
color: var(--primary-color);
|
|
||||||
text-shadow: 0 0 4px currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-bottom {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
opacity: 0.85;
|
|
||||||
text-align: center;
|
|
||||||
border-top: 1px solid var(--divider-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-bottom a {
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--fg-color);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer-bottom a:hover {
|
|
||||||
text-shadow: 0 0 5px currentColor;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
background-color: var(--contact-info-bg);
|
|
||||||
color: var(--primary-color);
|
|
||||||
box-shadow: 0 4px 10px var(--shadow-color);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-info a {
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--primary-color);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-info a:hover {
|
|
||||||
transform: translateX(8px);
|
|
||||||
color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-info .fa-icon {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.heart-anim {
|
|
||||||
display: inline-block;
|
|
||||||
animation: heartbeat 1.5s infinite ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes heartbeat {
|
|
||||||
0%, 100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/* ================================
|
|
||||||
HEADER - ESTILO BASE
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.bg-img {
|
|
||||||
background-image: url('/images/bg.png');
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*.mask {
|
|
||||||
background-color: var(--header-mask-color);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
-webkit-backdrop-filter: blur(4px);
|
|
||||||
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
font-family: 'Product Sans';
|
|
||||||
font-size: 3em;
|
|
||||||
font-weight: bolder;
|
|
||||||
color: var(--text-color);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.shadowed {
|
|
||||||
text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
RESPONSIVE HEADER TITLE
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.header-title {
|
|
||||||
font-size: 2em;
|
|
||||||
padding: 0 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/* ================================
|
|
||||||
LOGIN - CARD CONTAINER (VISUAL)
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
background-color: var(--login-bg) !important;
|
|
||||||
color: var(--text-color);
|
|
||||||
box-shadow: 0 0 10px var(--shadow-color);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
INPUTS VISUALES
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
input.form-control {
|
|
||||||
background-color: var(--input-bg);
|
|
||||||
color: var(--input-text);
|
|
||||||
border: 1px solid var(--input-border);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
LABELS PERSONALIZADAS
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.form-floating>label {
|
|
||||||
font-family: 'Product Sans';
|
|
||||||
font-size: 1.1em;
|
|
||||||
color: var(--label-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-floating>label::after {
|
|
||||||
background-color: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================================
|
|
||||||
BOTÓN VISUAL
|
|
||||||
================================== */
|
|
||||||
|
|
||||||
.login-button {
|
|
||||||
font-family: 'Product Sans' !important;
|
|
||||||
font-size: 1.3em !important;
|
|
||||||
font-weight: bold !important;
|
|
||||||
background-color: var(--login-btn-bg) !important;
|
|
||||||
color: var(--login-btn-text) !important;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button:hover {
|
|
||||||
background-color: var(--login-btn-hover) !important;
|
|
||||||
color: var(--login-btn-text-hover) !important;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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),
|
||||||
return { data: response.data.data, error: null };
|
...extraHeaders
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
data: null,
|
|
||||||
error: {
|
|
||||||
status: err.response?.data?.status || err.response?.status,
|
|
||||||
message: err.response?.data?.message || err.message,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const postData = async (endpoint, payload) => {
|
const axiosConfig = {
|
||||||
const headers = {
|
headers: combinedHeaders
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
|
||||||
...(payload instanceof FormData ? {} : { "Content-Type": "application/json" }),
|
|
||||||
};
|
|
||||||
const response = await axios.post(endpoint, payload, { headers });
|
|
||||||
await fetchData();
|
|
||||||
return response.data.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const postDataValidated = async (endpoint, payload) => {
|
|
||||||
try {
|
|
||||||
const headers = {
|
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
|
||||||
...(payload instanceof FormData ? {} : { "Content-Type": "application/json" }),
|
|
||||||
};
|
};
|
||||||
const response = await axios.post(endpoint, payload, { headers });
|
|
||||||
return { data: response.data.data, errors: null };
|
|
||||||
} catch (err) {
|
|
||||||
const raw = err.response?.data?.message;
|
|
||||||
let parsed = {};
|
|
||||||
|
|
||||||
try {
|
let response;
|
||||||
parsed = JSON.parse(raw);
|
if (method === "get") {
|
||||||
} catch {
|
response = await axios.get(endpoint, {
|
||||||
return { data: null, errors: { general: raw || err.message } };
|
...axiosConfig,
|
||||||
|
params: payload
|
||||||
|
});
|
||||||
|
} else if (method === "delete") {
|
||||||
|
response = await axios.delete(endpoint, {
|
||||||
|
...axiosConfig,
|
||||||
|
data: payload
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await axios[method](endpoint, payload, axiosConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { data: null, errors: parsed };
|
if (refresh) await fetchData();
|
||||||
|
return response.data;
|
||||||
|
} catch (err) {
|
||||||
|
const error = handleAxiosError(err);
|
||||||
|
if (!silent && error.status !== 422 && onError) {
|
||||||
|
onError(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 {
|
return {
|
||||||
data,
|
data, dataLoading, dataError,
|
||||||
dataLoading,
|
getData: (url, paramsOrOptions, refresh = true, h = {}, silent = false) => {
|
||||||
dataError,
|
const isOptionsObject =
|
||||||
getData,
|
paramsOrOptions &&
|
||||||
postData,
|
typeof paramsOrOptions === "object" &&
|
||||||
postDataValidated,
|
!Array.isArray(paramsOrOptions) &&
|
||||||
putData,
|
("params" in paramsOrOptions || "refresh" in paramsOrOptions || "headers" in paramsOrOptions || "silent" in paramsOrOptions);
|
||||||
deleteData,
|
|
||||||
deleteDataWithBody,
|
if (isOptionsObject) {
|
||||||
|
const {
|
||||||
|
params = null,
|
||||||
|
refresh: optionsRefresh = true,
|
||||||
|
headers = {},
|
||||||
|
silent: optionsSilent = false,
|
||||||
|
} = paramsOrOptions;
|
||||||
|
|
||||||
|
return requestWrapper("get", url, {
|
||||||
|
payload: params,
|
||||||
|
refresh: optionsRefresh,
|
||||||
|
extraHeaders: headers,
|
||||||
|
silent: optionsSilent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestWrapper("get", url, {
|
||||||
|
payload: paramsOrOptions,
|
||||||
|
refresh,
|
||||||
|
extraHeaders: h,
|
||||||
|
silent,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
postData: (url, body, refresh = true, silent = false) => requestWrapper("post", url, {
|
||||||
|
payload: body,
|
||||||
|
refresh,
|
||||||
|
silent,
|
||||||
|
}),
|
||||||
|
putData: (url, body, refresh = true, silent = false) => requestWrapper("put", url, {
|
||||||
|
payload: body,
|
||||||
|
refresh,
|
||||||
|
silent,
|
||||||
|
}),
|
||||||
|
deleteData: (url, refresh = true, silent = false) => requestWrapper("delete", url, {
|
||||||
|
refresh,
|
||||||
|
silent,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { parseJwt } from "../util/tokenUtils.js";
|
|
||||||
import NotificationModal from "../components/NotificationModal.jsx";
|
|
||||||
import axios from "axios";
|
|
||||||
import { useAuth } from "./useAuth.js";
|
|
||||||
import { useConfig } from "./useConfig.js";
|
|
||||||
|
|
||||||
const useSessionRenewal = () => {
|
|
||||||
const { logout } = useAuth();
|
|
||||||
const { config } = useConfig();
|
|
||||||
|
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
const [alreadyWarned, setAlreadyWarned] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
const decoded = parseJwt(token);
|
|
||||||
|
|
||||||
if (!token || !decoded?.exp) return;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const expTime = decoded.exp * 1000;
|
|
||||||
const timeLeft = expTime - now;
|
|
||||||
|
|
||||||
if (timeLeft <= 60000 && timeLeft > 0 && !alreadyWarned) {
|
|
||||||
setShowModal(true);
|
|
||||||
setAlreadyWarned(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeLeft <= 0) {
|
|
||||||
clearInterval(interval);
|
|
||||||
logout();
|
|
||||||
}
|
|
||||||
}, 10000); // revisa cada 10 segundos
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [alreadyWarned, logout]);
|
|
||||||
|
|
||||||
const handleRenew = async () => {
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
const decoded = parseJwt(token);
|
|
||||||
const now = Date.now();
|
|
||||||
const expTime = decoded?.exp * 1000;
|
|
||||||
|
|
||||||
if (!token || !decoded || now > expTime) {
|
|
||||||
logout();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.get(
|
|
||||||
`${config.apiConfig.baseUrl}${config.apiConfig.endpoints.auth.refreshToken}`,
|
|
||||||
null,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const newToken = response.data.data.token;
|
|
||||||
localStorage.setItem("token", newToken);
|
|
||||||
setShowModal(false);
|
|
||||||
setAlreadyWarned(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error renovando sesión:", err);
|
|
||||||
logout();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const modal = showModal && (
|
|
||||||
<NotificationModal
|
|
||||||
show={true}
|
|
||||||
onClose={() => {
|
|
||||||
setShowModal(false);
|
|
||||||
logout();
|
|
||||||
}}
|
|
||||||
title="¿Quieres seguir conectado?"
|
|
||||||
message="Tu sesión está a punto de expirar. ¿Quieres renovarla 1 hora más?"
|
|
||||||
variant="info"
|
|
||||||
buttons={[
|
|
||||||
{
|
|
||||||
label: "Renovar sesión",
|
|
||||||
variant: "success",
|
|
||||||
onClick: handleRenew,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Cerrar sesión",
|
|
||||||
variant: "danger",
|
|
||||||
onClick: () => {
|
|
||||||
logout();
|
|
||||||
setShowModal(false);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return { modal };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useSessionRenewal;
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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) }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
export const renderErrorAlert = (error, options = {}) => {
|
|
||||||
const { className = 'alert alert-danger alert-dismissible py-1 px-2 small', role = 'alert' } = options;
|
|
||||||
|
|
||||||
if (!error) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} role={role}>
|
|
||||||
{typeof error === 'string' ? error : 'An unexpected error occurred.'}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resetErrorIfEditEnds = (editMode, setError) => {
|
|
||||||
if (!editMode) setError(null);
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const CONSTANTS = {
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
export { CONSTANTS };
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const getNowAsLocalDatetime = () => {
|
|
||||||
const now = new Date();
|
|
||||||
const offset = now.getTimezoneOffset(); // en minutos
|
|
||||||
const local = new Date(now.getTime() - offset * 60000);
|
|
||||||
return local.toISOString().slice(0, 16);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { getNowAsLocalDatetime }
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
export const DateParser = {
|
|
||||||
sqlToString: (sqlDate) => {
|
|
||||||
const [datePart] = sqlDate.split('T');
|
|
||||||
const [year, month, day] = datePart.split('-');
|
|
||||||
return `${day}/${month}/${year}`;
|
|
||||||
},
|
|
||||||
|
|
||||||
timestampToString: (timestamp) => {
|
|
||||||
const [datePart] = timestamp.split('T');
|
|
||||||
const [year, month, day] = datePart.split('-');
|
|
||||||
return `${day}/${month}/${year}`;
|
|
||||||
},
|
|
||||||
|
|
||||||
isoToStringWithTime: (isoString) => {
|
|
||||||
if (!isoString) return '—';
|
|
||||||
|
|
||||||
const date = new Date(isoString);
|
|
||||||
if (isNaN(date)) return '—'; // Para proteger aún más por si llega basura
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('es-ES', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
timeZone: 'Europe/Madrid'
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
export const errorParser = (err) => {
|
|
||||||
const message = err.response?.data?.message;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(message);
|
|
||||||
return Object.values(parsed)[0];
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
} catch (e) {
|
|
||||||
return message || err.message || "Unknown error";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
export const generateSecurePassword = (length = 12) => {
|
|
||||||
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
||||||
const lower = 'abcdefghijklmnopqrstuvwxyz';
|
|
||||||
const digits = '0123456789';
|
|
||||||
const symbols = '!@#$%^&*'; // <- compatibles con bcrypt
|
|
||||||
const all = upper + lower + digits + symbols;
|
|
||||||
|
|
||||||
if (length < 8) length = 8;
|
|
||||||
|
|
||||||
const getRand = (chars) => chars[Math.floor(Math.random() * chars.length)];
|
|
||||||
|
|
||||||
let password = [
|
|
||||||
getRand(upper),
|
|
||||||
getRand(lower),
|
|
||||||
getRand(digits),
|
|
||||||
getRand(symbols),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (let i = password.length; i < length; i++) {
|
|
||||||
password.push(getRand(all));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = password.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[password[i], password[j]] = [password[j], password[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
return password.join('');
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export const parseJwt = (token) => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(atob(token.split('.')[1]));
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user