[REPO REFACTOR]: changed to a better git repository structure with branches

This commit is contained in:
2025-11-01 05:49:49 +01:00
parent 4d0f44e995
commit 589215b2bc
76 changed files with 3529 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
import { useState, useRef, useEffect, cloneElement } from 'react';
import { Button } from 'react-bootstrap';
import { AnimatePresence, motion as _motion } from 'framer-motion';
import '../css/AnimatedDropdown.css';
const AnimatedDropdown = ({
trigger,
icon,
variant = "secondary",
className = "",
buttonStyle = "",
show,
onToggle,
onMouseEnter,
onMouseLeave,
children
}) => {
const isControlled = show !== undefined;
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const dropdownRef = useRef(null);
const actualOpen = isControlled ? show : open;
const toggle = () => {
const newState = !actualOpen;
if (!isControlled) setOpen(newState);
onToggle?.(newState);
};
useEffect(() => {
const handleClickOutside = (e) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target) &&
!triggerRef.current?.contains(e.target)
) {
if (!isControlled) setOpen(false);
onToggle?.(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isControlled, onToggle]);
const triggerElement = trigger
? (typeof trigger === "function"
? trigger({ onClick: toggle, ref: triggerRef })
: cloneElement(trigger, { onClick: toggle, ref: triggerRef }))
: (
<Button
ref={triggerRef}
variant={variant}
className={`circle-btn ${buttonStyle}`}
onClick={toggle}
>
{icon}
</Button>
);
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
return (
<div
className={`position-relative d-inline-block`}
onClick={toggle}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
ref={triggerRef}
>
{triggerElement}
<AnimatePresence>
{actualOpen && (
<_motion.div
ref={dropdownRef}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.15 }}
className={dropdownClasses}
>
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
</_motion.div>
)}
</AnimatePresence>
</div>
);
};
export default AnimatedDropdown;

View File

@@ -0,0 +1,122 @@
import { useState, useRef, useEffect, cloneElement } from 'react';
import { Button } from 'react-bootstrap';
import { AnimatePresence, motion as _motion } from 'framer-motion';
import '@/css/AnimatedDropdown.css';
const AnimatedDropend = ({
trigger,
icon,
variant = "secondary",
className = "",
buttonStyle = "",
show,
onToggle,
onMouseEnter,
onMouseLeave,
children
}) => {
const isControlled = show !== undefined;
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
const dropdownRef = useRef(null);
const actualOpen = isControlled ? show : open;
const toggle = (forceValue) => {
const newState = typeof forceValue === "boolean" ? forceValue : !actualOpen;
if (!isControlled) setOpen(newState);
onToggle?.(newState);
};
useEffect(() => {
const handleClickOutside = (e) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target) &&
!triggerRef.current?.contains(e.target)
) {
if (!isControlled) setOpen(false);
onToggle?.(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isControlled, onToggle]);
const handleMouseEnter = () => {
if (!isControlled) setOpen(true);
onToggle?.(true);
onMouseEnter?.();
};
const handleMouseLeave = () => {
if (!isControlled) setOpen(false);
onToggle?.(false);
onMouseLeave?.();
};
const triggerElement = trigger
? (typeof trigger === "function"
? trigger({
onClick: e => {
e.stopPropagation();
toggle();
},
ref: triggerRef
})
: cloneElement(trigger, {
onClick: e => {
e.stopPropagation();
toggle();
},
ref: triggerRef
}))
: (
<Button
ref={triggerRef}
variant={variant}
className={`circle-btn ${buttonStyle}`}
onClick={toggle}
>
{icon}
</Button>
);
const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`;
return (
<div
className="position-relative d-inline-block dropend"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={triggerRef}
>
{triggerElement}
<AnimatePresence>
{actualOpen && (
<_motion.div
ref={dropdownRef}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.15 }}
className={dropdownClasses}
style={{
position: 'absolute',
top: '0',
left: '100%',
zIndex: 1000,
whiteSpace: 'nowrap'
}}
>
{typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children}
</_motion.div>
)}
</AnimatePresence>
</div>
);
};
export default AnimatedDropend;

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
import { useAuth } from "@/hooks/useAuth.js";
const IfRole = ({ roles, children }) => {
const { user, authStatus } = useAuth();
if (authStatus !== "authenticated") return null;
const userRole = user?.role;
return roles.includes(userRole) ? children : null;
};
export default IfRole;

View File

@@ -0,0 +1,120 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUser } from '@fortawesome/free-solid-svg-icons';
import { Form, Button, Alert, FloatingLabel, Row, Col } from 'react-bootstrap';
import PasswordInput from '@/components/Auth/PasswordInput.jsx';
import { useContext, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { AuthContext } from "@/context/AuthContext.jsx";
import CustomContainer from '@/components/CustomContainer.jsx';
import ContentWrapper from '@/components/ContentWrapper.jsx';
import '@/css/LoginForm.css';
const LoginForm = () => {
const { login, error } = useContext(AuthContext);
const navigate = useNavigate();
const [formState, setFormState] = useState({
emailOrUserName: "",
password: "",
keepLoggedIn: false
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormState((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formState.emailOrUserName);
const loginBody = {
password: formState.password,
keepLoggedIn: Boolean(formState.keepLoggedIn),
};
if (isEmail) {
loginBody.email = formState.emailOrUserName;
} else {
loginBody.userName = formState.emailOrUserName;
}
try {
await login(loginBody);
navigate("/");
} catch (err) {
console.error("Error de login:", err.message);
}
};
return (
<CustomContainer>
<ContentWrapper>
<div className="login-card card shadow p-5 rounded-5 mx-auto col-12 col-md-8 col-lg-6 col-xl-5 d-flex flex-column gap-4">
<h1 className="text-center">Inicio de sesión</h1>
<Form className="d-flex flex-column gap-5" onSubmit={handleSubmit}>
<div className="d-flex flex-column gap-3">
<FloatingLabel
controlId="floatingUsuario"
label={
<>
<FontAwesomeIcon icon={faUser} className="me-2" />
Usuario o Email
</>
}
>
<Form.Control
type="text"
placeholder=""
name="emailOrUserName"
value={formState.emailOrUserName}
onChange={handleChange}
className="rounded-4"
/>
</FloatingLabel>
<PasswordInput
value={formState.password}
onChange={handleChange}
name="password"
/>
<div className="d-flex flex-column flex-sm-row justify-content-between align-items-center gap-2">
<Form.Check
type="checkbox"
name="keepLoggedIn"
label="Mantener sesión iniciada"
className="text-secondary"
value={formState.keepLoggedIn}
onChange={(e) => { formState.keepLoggedIn = e.target.checked; setFormState({ ...formState }) }}
/>
{/*<Link disabled to="#" className="muted">
Olvidé mi contraseña
</Link>*/}
</div>
</div>
{error && (
<Alert variant="danger" className="text-center py-2 mb-0">
{error}
</Alert>
)}
<div className="text-center">
<Button type="submit" className="w-75 padding-4 rounded-4 border-0 shadow-sm login-button">
Iniciar sesión
</Button>
</div>
</Form>
</div>
</ContentWrapper>
</CustomContainer>
);
};
export default LoginForm;

View File

@@ -0,0 +1,48 @@
import { useState } from 'react';
import { Form, FloatingLabel, Button } from 'react-bootstrap';
import '@/css/PasswordInput.css';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEye, faEyeSlash, faKey } from '@fortawesome/free-solid-svg-icons';
const PasswordInput = ({ value, onChange, name = "password" }) => {
const [show, setShow] = useState(false);
const toggleShow = () => setShow(prev => !prev);
return (
<div className="position-relative w-100">
<FloatingLabel
controlId="passwordInput"
label={
<>
<FontAwesomeIcon icon={faKey} className="me-2" />
Contraseña
</>
}
>
<Form.Control
type={show ? "text" : "password"}
name={name}
value={value}
placeholder=""
onChange={onChange}
className="rounded-4 pe-5"
/>
</FloatingLabel>
<Button
variant="link"
className="show-button position-absolute end-0 top-50 translate-middle-y me-2"
onClick={toggleShow}
aria-label="Mostrar contraseña"
tabIndex={-1}
style={{ zIndex: 2 }}
>
<FontAwesomeIcon icon={show ? faEyeSlash : faEye} className='fa-lg' />
</Button>
</div>
);
};
export default PasswordInput;

View File

@@ -0,0 +1,78 @@
import PropTypes from 'prop-types';
import { Modal, Button, Form } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons';
import { useState } from 'react';
import PasswordInput from '@/components/Auth/PasswordInput';
import { renderErrorAlert } from '@/util/alertHelpers';
import '@/css/PasswordModal.css';
const PasswordModal = ({
show,
onClose,
onSubmit,
error = null,
loading = false
}) => {
const [password, setPassword] = useState("");
const handleSubmit = () => {
if (password.trim() === "") return;
onSubmit(password);
};
return (
<Modal show={show} onHide={onClose} centered>
<Modal.Header
style={{ backgroundColor: "var(--modal-bg)" }}
>
<Modal.Title>
<FontAwesomeIcon icon={faLock} className="me-2" />
Paste protegida
</Modal.Title>
</Modal.Header>
<Modal.Body style={{ backgroundColor: "var(--modal-body-bg)" }}>
<p className="mb-3">
Esta paste está protegida con contraseña. Introduce la clave para continuar.
</p>
{renderErrorAlert(error)}
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose} className='dialog-btn'>
Cancelar
</Button>
<Button
className='dialog-btn'
variant="primary"
onClick={handleSubmit}
disabled={loading || password.trim() === ""}
style={{
backgroundColor: "var(--btn-bg)",
borderColor: "var(--btn-bg)",
color: "var(--btn-text)"
}}
>
{loading ? "Verificando..." : "Acceder"}
</Button>
</Modal.Footer>
</Modal>
);
};
PasswordModal.propTypes = {
show: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
error: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
loading: PropTypes.bool
};
export default PasswordModal;

View File

@@ -0,0 +1,18 @@
import { Navigate } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth.js";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSpinner } from "@fortawesome/free-solid-svg-icons";
const ProtectedRoute = ({ minimumRoles, children }) => {
const { authStatus } = useAuth();
if (authStatus === "checking") return <FontAwesomeIcon icon={faSpinner} />; // o un loader si quieres
if (authStatus === "unauthenticated") return <Navigate to="/login" replace />;
if (authStatus === "authenticated" && minimumRoles) {
const userRole = JSON.parse(localStorage.getItem("user"))?.role;
if (!minimumRoles.includes(userRole)) return <Navigate to="/" replace />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,15 @@
import PropTypes from 'prop-types';
const ContentWrapper = ({ children }) => {
return (
<div className="container-xl">
{children}
</div>
);
}
ContentWrapper.propTypes = {
children: PropTypes.node.isRequired,
}
export default ContentWrapper;

View File

@@ -0,0 +1,47 @@
import Slider from 'react-slick';
import '@/css/CustomCarousel.css';
const CustomCarousel = ({ images }) => {
const settings = {
dots: false,
infinite: true,
speed: 500,
slidesToShow: 2,
slidesToScroll: 1,
arrows: false,
autoplay: true,
autoplaySpeed: 3000,
responsive: [
{
breakpoint: 768, // móviles
settings: {
slidesToShow: 1,
arrows: false,
autoplay: true,
autoplaySpeed: 3000,
dots: false,
infinite: true,
speed: 500
}
}
]
};
return (
<div className="my-4">
<Slider {...settings}>
{images.map((src, index) => (
<div key={index} className='carousel-img-wrapper'>
<img
src={src}
alt={`slide-${index}`}
className="carousel-img"
/>
</div>
))}
</Slider>
</div>
);
};
export default CustomCarousel;

View File

@@ -0,0 +1,15 @@
import PropTypes from 'prop-types';
const CustomContainer = ({ children }) => {
return (
<main className="px-4 py-5">
{children}
</main>
);
}
CustomContainer.propTypes = {
children: PropTypes.node.isRequired,
}
export default CustomContainer;

View File

@@ -0,0 +1,26 @@
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;

56
src/components/Footer.jsx Normal file
View File

@@ -0,0 +1,56 @@
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;

19
src/components/Header.jsx Normal file
View File

@@ -0,0 +1,19 @@
import '@/css/Header.css';
import { Link } from 'react-router-dom';
const Header = () => {
return (
<header className={`text-center bg-img`}>
<div className="m-0 p-5 mask">
<div className="d-flex flex-column justify-content-center align-items-center h-100">
<Link to='/' className='text-decoration-none'>
<h1 className='header-title m-0 text-white shadowed'>Tu página web</h1>
</Link>
</div>
</div>
</header>
);
}
export default Header;

View File

@@ -0,0 +1,10 @@
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
const LoadingIcon = () => {
return (
<FontAwesomeIcon icon={faSpinner} className='fa-spin fa-lg' />
);
}
export default LoadingIcon;

119
src/components/NavBar.jsx Normal file
View File

@@ -0,0 +1,119 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import '@/css/NavBar.css';
import ThemeButton from '@/components/ThemeButton.jsx';
import { Navbar, Nav, Container } from 'react-bootstrap';
import SearchToolbar from './SearchToolbar';
import { useSearch } from "@/context/SearchContext";
import NotificationModal from './NotificationModal';
const NavBar = () => {
const [expanded, setExpanded] = useState(false);
const [isLg, setIsLg] = useState(window.innerWidth >= 992);
const [isXs, setIsXs] = useState(window.innerWidth < 576);
const { searchTerm, setSearchTerm } = useSearch();
const [showContactModal, setShowContactModal] = useState(false);
useEffect(() => {
const handleResize = () => {
setIsLg(window.innerWidth >= 992 && window.innerWidth < 1200);
setIsXs(window.innerWidth < 576);
};
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 992) {
setExpanded(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
});
return (
<>
<Navbar
expand="lg"
sticky="top"
expanded={expanded}
onToggle={() => setExpanded(!expanded)}
className='shadow-none custom-border-bottom'
>
<Container fluid>
{/* brand */}
<Nav.Item
title="mpaste"
className={`navbar-brand`}
>
<div className="d-flex align-items-center gap-2">
<img src='/images/favicon.svg' width={44} height={44} />
<h3 className='m-0 p-0'>mpaste</h3>
</div>
</Nav.Item>
{/* ThemeButton SIEMPRE fijo */}
<div className="order-lg-2 ms-auto me-2">
<ThemeButton onlyIcon={isXs} />
</div>
{/* burger */}
<Navbar.Toggle
aria-controls="main-navbar"
className="custom-toggler border-0 order-lg-3"
>
<svg width="30" height="30" viewBox="0 0 30 30">
<path
d="M4 7h22M4 15h22M4 23h22"
stroke="var(--navbar-link-color)"
strokeWidth="3"
strokeLinecap="round"
strokeMiterlimit="10"
/>
</svg>
</Navbar.Toggle>
{/* links y search que colapsan */}
<Navbar.Collapse id="main-navbar" className="order-lg-1">
<Nav
className={`me-auto gap-3 w-100 ${expanded ? "flex-column align-items-start mt-3 mb-2" : "d-flex align-items-center"}`}
>
<Nav.Link as={Link} to="/" onClick={() => setExpanded(false)}>inicio</Nav.Link>
<Nav.Link as={Link} onClick={() => { setShowContactModal(true); setExpanded(false); }}>sugerencias</Nav.Link>
<div className="w-50">
<SearchToolbar
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
</div>
</Nav>
</Navbar.Collapse>
</Container>
</Navbar>
{/* Contact Modal */}
<NotificationModal
show={showContactModal}
onClose={() => setShowContactModal(false)}
title="Contacto"
message={
<span>
Si tienes alguna pregunta o sugerencia, me puedes escribir a mi correo: <br />
<strong>jose [arroba] miarma.net</strong>
</span>
}
variant=""
buttons={[
{ label: "Cerrar", variant: "secondary", onClick: () => setShowContactModal(false) }
]}
/>
</>
);
};
export default NavBar;

View File

@@ -0,0 +1,70 @@
import PropTypes from 'prop-types';
import { Modal, Button } from 'react-bootstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faCircleCheck,
faCircleXmark,
faCircleExclamation,
faCircleInfo
} from '@fortawesome/free-solid-svg-icons';
import '@/css/NotificationModal.css';
const iconMap = {
success: faCircleCheck,
danger: faCircleXmark,
warning: faCircleExclamation,
info: faCircleInfo
};
const NotificationModal = ({
show,
onClose,
title,
message,
variant = "info",
buttons = [{ label: "Aceptar", variant: "primary", onClick: onClose }]
}) => {
return (
<Modal show={show} onHide={onClose} centered>
<Modal.Header>
<Modal.Title>
<FontAwesomeIcon icon={iconMap[variant] || faCircleInfo} className="me-2" />
{title}
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="mb-0">{message}</p>
</Modal.Body>
<Modal.Footer>
{buttons.map((btn, index) => (
<Button
key={index}
variant={btn.variant || "primary"}
onClick={btn.onClick || onClose}
>
{btn.label}
</Button>
))}
</Modal.Footer>
</Modal>
);
};
NotificationModal.propTypes = {
show: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
message: PropTypes.string.isRequired,
variant: PropTypes.oneOf(['success', 'danger', 'warning', 'info']),
buttons: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
variant: PropTypes.string,
onClick: PropTypes.func
})
)
};
export default NotificationModal;

View File

@@ -0,0 +1,59 @@
import Editor from "@monaco-editor/react";
import { useTheme } from "@/hooks/useTheme";
import { useRef } from "react";
import PropTypes from "prop-types";
import { loader } from '@monaco-editor/react';
loader.config({
'vs/nls': {
availableLanguages: { '*': 'es' },
},
});
const CodeEditor = ({ className = "", syntax, readOnly, onChange, value }) => {
const { theme } = useTheme();
const editorRef = useRef(null);
const onMount = (editor) => {
editorRef.current = editor;
editor.focus();
}
return (
<div className={`code-editor ${className}`}>
<Editor
language={syntax || "plaintext"}
value={value || ""}
theme={theme === "dark" ? "vs-dark" : "vs-light"}
onChange={(value) => onChange?.(value)}
onMount={onMount}
options={{
minimap: { enabled: false },
automaticLayout: true,
fontFamily: 'Fira Code',
fontLigatures: true,
fontSize: 18,
lineHeight: 1.5,
scrollbar: { verticalScrollbarSize: 0 },
wordWrap: "on",
formatOnPaste: true,
suggest: {
showFields: true,
showFunctions: true,
},
readOnly: readOnly || false,
}}
/>
</div>
);
};
CodeEditor.propTypes = {
className: PropTypes.string,
syntax: PropTypes.string,
readOnly: PropTypes.bool,
onChange: PropTypes.func,
value: PropTypes.string,
};
export default CodeEditor;

View File

@@ -0,0 +1,235 @@
import { useState, useEffect } from "react";
import { Form, Button, Row, Col, FloatingLabel, Alert } from "react-bootstrap";
import '@/css/PastePanel.css';
import PasswordInput from "@/components/Auth/PasswordInput";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCode, faHeader } from "@fortawesome/free-solid-svg-icons";
import CodeEditor from "./CodeEditor";
import PublicPasteItem from "./PublicPasteItem";
import { useParams, useNavigate } from "react-router-dom";
import { useDataContext } from "@/hooks/useDataContext";
import PasswordModal from "@/components/Auth/PasswordModal.jsx";
const PastePanel = ({ onSubmit, publicPastes }) => {
const { paste_key } = useParams();
const navigate = useNavigate();
const { getData } = useDataContext();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [syntax, setSyntax] = useState("");
const [burnAfter, setBurnAfter] = useState(false);
const [isPrivate, setIsPrivate] = useState(false);
const [password, setPassword] = useState("");
const [selectedPaste, setSelectedPaste] = useState(null);
const [error, setError] = useState(null);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
const paste = {
title,
content,
syntax,
burn_after: burnAfter,
is_private: isPrivate,
password: password || null,
};
if (onSubmit) onSubmit(paste);
};
const handleSelectPaste = async (key) => {
navigate(`/${key}`);
};
const fetchPaste = async (key, pwd = "") => {
const url = `https://api.miarma.net/mpaste/v1/pastes/${key}`;
const { data, error } = await getData(url, {}, {
'X-Paste-Password': pwd
});
if (error) {
if (error?.status === 401) {
setShowPasswordModal(true);
return;
} else {
setError(error);
setSelectedPaste(null);
return;
}
}
setError(null);
setSelectedPaste(data);
setTitle(data.title);
setContent(data.content);
setSyntax(data.syntax || "plaintext");
};
useEffect(() => {
if (paste_key) fetchPaste(paste_key);
}, [paste_key]);
return (
<>
<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">
<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">
<div className="public-pastes d-flex flex-column flex-fill overflow-hidden">
<h4>pastes públicas</h4>
<hr />
<div className="overflow-auto flex-fill" style={{ scrollbarWidth: 'none' }}>
{publicPastes && publicPastes.length > 0 ? (
publicPastes.map((paste) => (
<PublicPasteItem
key={paste.paste_key}
paste={paste}
onSelect={handleSelectPaste}
/>
))
) : (
<p>No hay pastes públicas disponibles.</p>
)}
</div>
</div>
</Col>
<Col xs={12} lg={7} className="d-flex flex-column flex-fill min-h-0 overflow-hidden">
<CodeEditor
className="flex-fill custom-border rounded-4 overflow-hidden pt-4 pe-4"
syntax={syntax}
readOnly={!!selectedPaste}
onChange={selectedPaste ? undefined : setContent}
value={content}
/>
</Col>
<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">
<FloatingLabel
controlId="titleInput"
label={
<span className={selectedPaste ? "text-white" : ""}>
<FontAwesomeIcon icon={faHeader} className="me-2" />
Título
</span>
}
>
<Form.Control
disabled={!!selectedPaste}
type="text"
placeholder="Título de la paste"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</FloatingLabel>
<FloatingLabel
controlId="syntaxSelect"
label={
<>
<FontAwesomeIcon icon={faCode} className="me-2" />
Sintaxis
</>
}
>
<Form.Select
disabled={!!selectedPaste}
value={syntax}
onChange={(e) => setSyntax(e.target.value)}
>
<option value="">Sin resaltado</option>
<option value="javascript">JavaScript</option>
<option value="python">Python</option>
<option value="java">Java</option>
<option value="c">C</option>
<option value="cpp">C++</option>
<option value="bash">Bash</option>
<option value="html">HTML</option>
<option value="css">CSS</option>
<option value="sql">SQL</option>
<option value="julia">Julia</option>
<option value="json">JSON</option>
<option value="xml">XML</option>
<option value="yaml">YAML</option>
<option value="php">PHP</option>
<option value="ruby">Ruby</option>
<option value="go">Go</option>
<option value="rust">Rust</option>
<option value="typescript">TypeScript</option>
<option value="kotlin">Kotlin</option>
<option value="swift">Swift</option>
<option value="csharp">C#</option>
<option value="perl">Perl</option>
<option value="r">R</option>
<option value="dart">Dart</option>
<option value="lua">Lua</option>
<option value="haskell">Haskell</option>
<option value="scala">Scala</option>
<option value="objectivec">Objective-C</option>
</Form.Select>
</FloatingLabel>
<Form.Check
type="switch"
disabled={!!selectedPaste}
id="burnAfter"
label="volátil"
checked={burnAfter}
onChange={(e) => setBurnAfter(e.target.checked)}
className="ms-1 d-flex gap-2 align-items-center"
/>
<Form.Check
type="switch"
disabled={!!selectedPaste}
id="isPrivate"
label="privado"
checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)}
className="ms-1 d-flex gap-2 align-items-center"
/>
{isPrivate && (
<PasswordInput onChange={(e) => setPassword(e.target.value)} />
)}
<div className="d-flex justify-content-end">
<Button
variant="primary"
type="submit"
disabled={!!selectedPaste}
>
Crear paste
</Button>
</div>
</div>
</Col>
</Row>
</Form>
</div>
<PasswordModal
show={showPasswordModal}
onClose={() => setShowPasswordModal(false)}
onSubmit={(pwd) => {
setShowPasswordModal(false);
fetchPaste(paste_key, pwd); // reintentas con la pass
}}
/>
</>
);
};
export default PastePanel;

View File

@@ -0,0 +1,29 @@
import PropTypes from "prop-types";
import { Link } from "react-router-dom";
const trimContent = (text, maxLength = 80) => {
if (!text) return "";
return text.length <= maxLength ? text : text.slice(0, maxLength) + "...";
};
const PublicPasteItem = ({ paste, onSelect }) => {
return (
<div className="public-paste-item p-2 mb-2 rounded custom-border" style={{ cursor: "pointer" }} onClick={() => onSelect(paste.paste_key)}>
<h5 className="m-0">{paste.title}</h5>
<p className="m-0 text-truncate">{trimContent(paste.content, 100)}</p>
<small className="custom-text-muted">
{new Date(paste.created_at).toLocaleString()}
</small>
</div>
);
};
PublicPasteItem.propTypes = {
paste: PropTypes.shape({
title: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
created_at: PropTypes.string.isRequired,
}).isRequired,
};
export default PublicPasteItem;

View File

@@ -0,0 +1,15 @@
import '@/css/SearchToolbar.css';
const SearchToolbar = ({ searchTerm, onSearchChange }) => (
<div className="search-toolbar">
<input
type="text"
className="search-input"
placeholder="Buscar..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
);
export default SearchToolbar;

View File

@@ -0,0 +1,18 @@
import { useTheme } from "@/hooks/useTheme.js";
import "@/css/ThemeButton.css";
export default function ThemeButton({ className, onlyIcon}) {
const { theme, toggleTheme } = useTheme();
return (
<button className={`theme-toggle ${className}`} onClick={toggleTheme}>
{
onlyIcon ? (
theme === "dark" ? ("🌞") : ("🌙")
) : (
theme === "dark" ? ("🌞 tema claro") : ("🌙 tema oscuro")
)
}
</button>
);
}