[REPO REFACTOR]: changed to a better git repository structure with branches
This commit is contained in:
92
src/components/AnimatedDropdown.jsx
Normal file
92
src/components/AnimatedDropdown.jsx
Normal 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;
|
||||
122
src/components/AnimatedDropend.jsx
Normal file
122
src/components/AnimatedDropend.jsx
Normal 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;
|
||||
8
src/components/Auth/IfAuthenticated.jsx
Normal file
8
src/components/Auth/IfAuthenticated.jsx
Normal 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;
|
||||
8
src/components/Auth/IfNotAuthenticated.jsx
Normal file
8
src/components/Auth/IfNotAuthenticated.jsx
Normal 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;
|
||||
13
src/components/Auth/IfRole.jsx
Normal file
13
src/components/Auth/IfRole.jsx
Normal 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;
|
||||
120
src/components/Auth/LoginForm.jsx
Normal file
120
src/components/Auth/LoginForm.jsx
Normal 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;
|
||||
48
src/components/Auth/PasswordInput.jsx
Normal file
48
src/components/Auth/PasswordInput.jsx
Normal 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;
|
||||
78
src/components/Auth/PasswordModal.jsx
Normal file
78
src/components/Auth/PasswordModal.jsx
Normal 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;
|
||||
18
src/components/Auth/ProtectedRoute.jsx
Normal file
18
src/components/Auth/ProtectedRoute.jsx
Normal 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;
|
||||
15
src/components/ContentWrapper.jsx
Normal file
15
src/components/ContentWrapper.jsx
Normal 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;
|
||||
47
src/components/CustomCarousel.jsx
Normal file
47
src/components/CustomCarousel.jsx
Normal 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;
|
||||
15
src/components/CustomContainer.jsx
Normal file
15
src/components/CustomContainer.jsx
Normal 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;
|
||||
26
src/components/CustomModal.jsx
Normal file
26
src/components/CustomModal.jsx
Normal 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
56
src/components/Footer.jsx
Normal 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
19
src/components/Header.jsx
Normal 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;
|
||||
10
src/components/LoadingIcon.jsx
Normal file
10
src/components/LoadingIcon.jsx
Normal 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
119
src/components/NavBar.jsx
Normal 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;
|
||||
70
src/components/NotificationModal.jsx
Normal file
70
src/components/NotificationModal.jsx
Normal 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;
|
||||
59
src/components/Pastes/CodeEditor.jsx
Normal file
59
src/components/Pastes/CodeEditor.jsx
Normal 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;
|
||||
235
src/components/Pastes/PastePanel.jsx
Normal file
235
src/components/Pastes/PastePanel.jsx
Normal 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;
|
||||
29
src/components/Pastes/PublicPasteItem.jsx
Normal file
29
src/components/Pastes/PublicPasteItem.jsx
Normal 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;
|
||||
15
src/components/SearchToolbar.jsx
Normal file
15
src/components/SearchToolbar.jsx
Normal 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;
|
||||
18
src/components/ThemeButton.jsx
Normal file
18
src/components/ThemeButton.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user