diff --git a/index.html b/index.html new file mode 100644 index 0000000..3b0059e --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + mpaste + + +
+ + + diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..30e99a0 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] + } \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..171e057 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "miarma-base", + "private": true, + "version": "1.0.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^7.0.0", + "@fortawesome/fontawesome-svg-core": "^7.0.0", + "@fortawesome/free-brands-svg-icons": "^7.0.0", + "@fortawesome/free-regular-svg-icons": "^7.0.0", + "@fortawesome/free-solid-svg-icons": "^7.0.0", + "@fortawesome/react-fontawesome": "^0.2.3", + "@monaco-editor/react": "^4.7.0", + "axios": "^1.11.0", + "bootstrap": "^5.3.7", + "date-fns": "^4.1.0", + "framer-motion": "^12.23.12", + "monaco-editor": "^0.53.0", + "react": "^19.1.0", + "react-bootstrap": "^2.10.10", + "react-dom": "^19.1.0", + "react-router-dom": "^7.7.1", + "react-slick": "^0.30.3", + "slick-carousel": "^1.8.1" + }, + "devDependencies": { + "@eslint/js": "^9.30.1", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.6.0", + "electron": "^37.2.5", + "eslint": "^9.30.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "vite": "^7.0.4" + } +} diff --git a/public/config/settings.dev.json b/public/config/settings.dev.json new file mode 100644 index 0000000..fc86e32 --- /dev/null +++ b/public/config/settings.dev.json @@ -0,0 +1,12 @@ +{ + "apiConfig": { + "baseUrl": "https://api.miarma.net/mpaste", + "endpoints": { + "pastes": { + "all": "/raw/v1/pastes", + "byId": "/raw/v1/pastes/:paste_id", + "byKey": "/v1/pastes/:paste_key" + } + } + } +} diff --git a/public/config/settings.prod.json b/public/config/settings.prod.json new file mode 100644 index 0000000..fc86e32 --- /dev/null +++ b/public/config/settings.prod.json @@ -0,0 +1,12 @@ +{ + "apiConfig": { + "baseUrl": "https://api.miarma.net/mpaste", + "endpoints": { + "pastes": { + "all": "/raw/v1/pastes", + "byId": "/raw/v1/pastes/:paste_id", + "byKey": "/v1/pastes/:paste_key" + } + } + } +} diff --git a/public/fonts/FiraCode.ttf b/public/fonts/FiraCode.ttf new file mode 100644 index 0000000..d7077f1 Binary files /dev/null and b/public/fonts/FiraCode.ttf differ diff --git a/public/fonts/OpenSans.ttf b/public/fonts/OpenSans.ttf new file mode 100644 index 0000000..ac587b4 Binary files /dev/null and b/public/fonts/OpenSans.ttf differ diff --git a/public/fonts/ProductSansBold.ttf b/public/fonts/ProductSansBold.ttf new file mode 100644 index 0000000..d847195 Binary files /dev/null and b/public/fonts/ProductSansBold.ttf differ diff --git a/public/fonts/ProductSansBoldItalic.ttf b/public/fonts/ProductSansBoldItalic.ttf new file mode 100644 index 0000000..129d12d Binary files /dev/null and b/public/fonts/ProductSansBoldItalic.ttf differ diff --git a/public/fonts/ProductSansItalic.ttf b/public/fonts/ProductSansItalic.ttf new file mode 100644 index 0000000..5fc56d4 Binary files /dev/null and b/public/fonts/ProductSansItalic.ttf differ diff --git a/public/fonts/ProductSansRegular.ttf b/public/fonts/ProductSansRegular.ttf new file mode 100644 index 0000000..c0442ee Binary files /dev/null and b/public/fonts/ProductSansRegular.ttf differ diff --git a/public/images/bg.png b/public/images/bg.png new file mode 100644 index 0000000..386fa3a Binary files /dev/null and b/public/images/bg.png differ diff --git a/public/images/favicon.svg b/public/images/favicon.svg new file mode 100644 index 0000000..573b9b0 --- /dev/null +++ b/public/images/favicon.svg @@ -0,0 +1,389 @@ + + diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..44523fd --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,19 @@ +import NavBar from '@/components/NavBar.jsx'; +import { Route, Routes, useLocation } from 'react-router-dom' +import Home from '@/pages/Home.jsx' + +function App() { + return ( + <> + +
+ + } /> + } /> + +
+ + ) +} + +export default App diff --git a/src/api/axiosInstance.js b/src/api/axiosInstance.js new file mode 100644 index 0000000..5a4f265 --- /dev/null +++ b/src/api/axiosInstance.js @@ -0,0 +1,14 @@ +import axios from "axios"; + +const createAxiosInstance = (baseURL, token) => { + const instance = axios.create({ + baseURL, + headers: { + ...(token && { Authorization: `Bearer ${token}` }), + }, + }); + + return instance; +}; + +export default createAxiosInstance; diff --git a/src/components/AnimatedDropdown.jsx b/src/components/AnimatedDropdown.jsx new file mode 100644 index 0000000..c8c0e08 --- /dev/null +++ b/src/components/AnimatedDropdown.jsx @@ -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 })) + : ( + + ); + + const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`; + + return ( +
+ {triggerElement} + + + {actualOpen && ( + <_motion.div + ref={dropdownRef} + initial={{ opacity: 0, scale: 0.98 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.98 }} + transition={{ duration: 0.15 }} + className={dropdownClasses} + > + {typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children} + + )} + +
+ ); +}; + +export default AnimatedDropdown; diff --git a/src/components/AnimatedDropend.jsx b/src/components/AnimatedDropend.jsx new file mode 100644 index 0000000..63e98e5 --- /dev/null +++ b/src/components/AnimatedDropend.jsx @@ -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 + })) + : ( + + ); + + const dropdownClasses = `dropdown-menu show shadow rounded-4 px-2 py-2 ${className}`; + + return ( +
+ {triggerElement} + + + {actualOpen && ( + <_motion.div + ref={dropdownRef} + initial={{ opacity: 0, x: -10 }} + animate={{ opacity: 1, x: 0 }} + exit={{ opacity: 0, x: -10 }} + transition={{ duration: 0.15 }} + className={dropdownClasses} + style={{ + position: 'absolute', + top: '0', + left: '100%', + zIndex: 1000, + whiteSpace: 'nowrap' + }} + > + {typeof children === "function" ? children({ closeDropdown: () => toggle(false) }) : children} + + )} + +
+ ); +}; + +export default AnimatedDropend; diff --git a/src/components/Auth/IfAuthenticated.jsx b/src/components/Auth/IfAuthenticated.jsx new file mode 100644 index 0000000..9504174 --- /dev/null +++ b/src/components/Auth/IfAuthenticated.jsx @@ -0,0 +1,8 @@ +import { useAuth } from "@/hooks/useAuth.js"; + +const IfAuthenticated = ({ children }) => { + const { authStatus } = useAuth(); + return authStatus === "authenticated" ? children : null; +}; + +export default IfAuthenticated; diff --git a/src/components/Auth/IfNotAuthenticated.jsx b/src/components/Auth/IfNotAuthenticated.jsx new file mode 100644 index 0000000..53593c1 --- /dev/null +++ b/src/components/Auth/IfNotAuthenticated.jsx @@ -0,0 +1,8 @@ +import { useAuth } from "@/hooks/useAuth.js"; + +const IfNotAuthenticated = ({ children }) => { + const { authStatus } = useAuth(); + return authStatus === "unauthenticated" ? children : null; +}; + +export default IfNotAuthenticated; diff --git a/src/components/Auth/IfRole.jsx b/src/components/Auth/IfRole.jsx new file mode 100644 index 0000000..336791a --- /dev/null +++ b/src/components/Auth/IfRole.jsx @@ -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; diff --git a/src/components/Auth/LoginForm.jsx b/src/components/Auth/LoginForm.jsx new file mode 100644 index 0000000..fd707cd --- /dev/null +++ b/src/components/Auth/LoginForm.jsx @@ -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 ( + + +
+

Inicio de sesión

+
+
+ + + Usuario o Email + + } + > + + + + + +
+ { formState.keepLoggedIn = e.target.checked; setFormState({ ...formState }) }} + /> + {/* + Olvidé mi contraseña + */} +
+
+ + {error && ( + + {error} + + )} + +
+ +
+
+
+
+
+ ); +}; + + +export default LoginForm; diff --git a/src/components/Auth/PasswordInput.jsx b/src/components/Auth/PasswordInput.jsx new file mode 100644 index 0000000..9fa1897 --- /dev/null +++ b/src/components/Auth/PasswordInput.jsx @@ -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 ( +
+ + + Contraseña + + } + > + + + + +
+ ); +}; + +export default PasswordInput; diff --git a/src/components/Auth/PasswordModal.jsx b/src/components/Auth/PasswordModal.jsx new file mode 100644 index 0000000..fd1a5b0 --- /dev/null +++ b/src/components/Auth/PasswordModal.jsx @@ -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 ( + + + + + Paste protegida + + + + +

+ Esta paste está protegida con contraseña. Introduce la clave para continuar. +

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

Tu página web

+ +
+
+
+ ); +} + +export default Header; \ No newline at end of file diff --git a/src/components/LoadingIcon.jsx b/src/components/LoadingIcon.jsx new file mode 100644 index 0000000..e877e43 --- /dev/null +++ b/src/components/LoadingIcon.jsx @@ -0,0 +1,10 @@ +import { faSpinner } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +const LoadingIcon = () => { + return ( + + ); +} + +export default LoadingIcon; \ No newline at end of file diff --git a/src/components/NavBar.jsx b/src/components/NavBar.jsx new file mode 100644 index 0000000..913c703 --- /dev/null +++ b/src/components/NavBar.jsx @@ -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 ( + <> + setExpanded(!expanded)} + className='shadow-none custom-border-bottom' + > + + {/* brand */} + +
+ +

mpaste

+
+
+ + {/* ThemeButton SIEMPRE fijo */} +
+ +
+ + {/* burger */} + + + + + + + {/* links y search que colapsan */} + + + +
+
+ {/* Contact Modal */} + setShowContactModal(false)} + title="Contacto" + message={ + + Si tienes alguna pregunta o sugerencia, me puedes escribir a mi correo:
+ jose [arroba] miarma.net +
+ } + variant="" + buttons={[ + { label: "Cerrar", variant: "secondary", onClick: () => setShowContactModal(false) } + ]} + /> + + ); +}; + +export default NavBar; \ No newline at end of file diff --git a/src/components/NotificationModal.jsx b/src/components/NotificationModal.jsx new file mode 100644 index 0000000..53d9343 --- /dev/null +++ b/src/components/NotificationModal.jsx @@ -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 ( + + + + + {title} + + + + +

{message}

+
+ + + {buttons.map((btn, index) => ( + + ))} + +
+ ); +}; + +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; diff --git a/src/components/Pastes/CodeEditor.jsx b/src/components/Pastes/CodeEditor.jsx new file mode 100644 index 0000000..5839863 --- /dev/null +++ b/src/components/Pastes/CodeEditor.jsx @@ -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 ( +
+ 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, + }} + /> +
+ ); +}; + +CodeEditor.propTypes = { + className: PropTypes.string, + syntax: PropTypes.string, + readOnly: PropTypes.bool, + onChange: PropTypes.func, + value: PropTypes.string, +}; + +export default CodeEditor; diff --git a/src/components/Pastes/PastePanel.jsx b/src/components/Pastes/PastePanel.jsx new file mode 100644 index 0000000..b6cd7b6 --- /dev/null +++ b/src/components/Pastes/PastePanel.jsx @@ -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 ( + <> +
+ {error && + setError(null)} dismissible> + + { + error.status == 404 ? "404: Paste no encontrada." : + "Ha ocurrido un error al cargar la paste." + } + + + } + +
+ + +
+

pastes públicas

+
+
+ {publicPastes && publicPastes.length > 0 ? ( + publicPastes.map((paste) => ( + + )) + ) : ( +

No hay pastes públicas disponibles.

+ )} +
+
+ + + + + + + +
+ + + Título + + } + > + setTitle(e.target.value)} + /> + + + + + Sintaxis + + } + > + setSyntax(e.target.value)} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setBurnAfter(e.target.checked)} + className="ms-1 d-flex gap-2 align-items-center" + /> + + setIsPrivate(e.target.checked)} + className="ms-1 d-flex gap-2 align-items-center" + /> + + {isPrivate && ( + setPassword(e.target.value)} /> + )} + +
+ +
+
+ +
+
+
+ setShowPasswordModal(false)} + onSubmit={(pwd) => { + setShowPasswordModal(false); + fetchPaste(paste_key, pwd); // reintentas con la pass + }} + /> + + ); + +}; + +export default PastePanel; diff --git a/src/components/Pastes/PublicPasteItem.jsx b/src/components/Pastes/PublicPasteItem.jsx new file mode 100644 index 0000000..8d19849 --- /dev/null +++ b/src/components/Pastes/PublicPasteItem.jsx @@ -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 ( +
onSelect(paste.paste_key)}> +
{paste.title}
+

{trimContent(paste.content, 100)}

+ + {new Date(paste.created_at).toLocaleString()} + +
+ ); +}; + +PublicPasteItem.propTypes = { + paste: PropTypes.shape({ + title: PropTypes.string.isRequired, + content: PropTypes.string.isRequired, + created_at: PropTypes.string.isRequired, + }).isRequired, +}; + +export default PublicPasteItem; diff --git a/src/components/SearchToolbar.jsx b/src/components/SearchToolbar.jsx new file mode 100644 index 0000000..96b28da --- /dev/null +++ b/src/components/SearchToolbar.jsx @@ -0,0 +1,15 @@ +import '@/css/SearchToolbar.css'; + +const SearchToolbar = ({ searchTerm, onSearchChange }) => ( +
+ onSearchChange(e.target.value)} + /> +
+); + +export default SearchToolbar; \ No newline at end of file diff --git a/src/components/ThemeButton.jsx b/src/components/ThemeButton.jsx new file mode 100644 index 0000000..08602e5 --- /dev/null +++ b/src/components/ThemeButton.jsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx new file mode 100644 index 0000000..8f2b2a4 --- /dev/null +++ b/src/context/AuthContext.jsx @@ -0,0 +1,98 @@ +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 ( + + {children} + + ); +}; diff --git a/src/context/ConfigContext.jsx b/src/context/ConfigContext.jsx new file mode 100644 index 0000000..25b59bc --- /dev/null +++ b/src/context/ConfigContext.jsx @@ -0,0 +1,41 @@ +import { createContext, useState, useEffect } from "react"; +import PropTypes from "prop-types"; + +const ConfigContext = createContext(); + +export const ConfigProvider = ({ children }) => { + const [config, setConfig] = useState(null); + const [configLoading, setLoading] = useState(true); + const [configError, setError] = useState(null); + + useEffect(() => { + const fetchConfig = async () => { + try { + const response = import.meta.env.MODE === 'production' + ? await fetch("/config/settings.prod.json") + : await fetch("/config/settings.dev.json"); + if (!response.ok) throw new Error("Error al cargar settings.*.json"); + const json = await response.json(); + setConfig(json); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + fetchConfig(); + }, []); + + return ( + + {children} + + ); +}; + +ConfigProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export {ConfigContext}; \ No newline at end of file diff --git a/src/context/DataContext.jsx b/src/context/DataContext.jsx new file mode 100644 index 0000000..a99a74e --- /dev/null +++ b/src/context/DataContext.jsx @@ -0,0 +1,23 @@ +import { createContext } from "react"; +import PropTypes from "prop-types"; +import { useData } from "../hooks/useData"; + +export const DataContext = createContext(); + +export const DataProvider = ({ config, children }) => { + const data = useData(config); + + return ( + + {children} + + ); +}; + +DataProvider.propTypes = { + config: PropTypes.shape({ + baseUrl: PropTypes.string.isRequired, + params: PropTypes.object, + }).isRequired, + children: PropTypes.node.isRequired, +}; \ No newline at end of file diff --git a/src/context/SearchContext.jsx b/src/context/SearchContext.jsx new file mode 100644 index 0000000..de2600d --- /dev/null +++ b/src/context/SearchContext.jsx @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react"; +import { useSearchBar } from "@/hooks/useSearchBar"; + +const SearchContext = createContext(); + +export const SearchProvider = ({ children }) => { + const search = useSearchBar(); + return ( + + {children} + + ); +}; + +export const useSearch = () => useContext(SearchContext); diff --git a/src/context/ThemeContext.jsx b/src/context/ThemeContext.jsx new file mode 100644 index 0000000..043a201 --- /dev/null +++ b/src/context/ThemeContext.jsx @@ -0,0 +1,31 @@ +import { createContext, useEffect, useState } from "react"; + +export const ThemeContext = createContext(); + +export const ThemeProvider = ({ children }) => { + const [theme, setTheme] = useState(() => { + return ( + localStorage.getItem("theme") || + (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") + ); + }); + + useEffect(() => { + const root = document.documentElement; + document.body.classList.remove("light", "dark"); + document.body.classList.add(theme); + root.classList.remove("light", "dark"); + root.classList.add(theme); + localStorage.setItem("theme", theme); + }, [theme]); + + const toggleTheme = () => { + setTheme((prev) => (prev === "light" ? "dark" : "light")); + }; + + return ( + + {children} + + ); +}; diff --git a/src/css/AnimatedDropdown.css b/src/css/AnimatedDropdown.css new file mode 100644 index 0000000..ba10c6e --- /dev/null +++ b/src/css/AnimatedDropdown.css @@ -0,0 +1,28 @@ +.dropdown-menu .dropdown-divider { + border-top: 1px solid var(--divider-color); +} + +.dropdown-menu { + background-color: var(--bg-color) !important; + color: var(--text-color) !important; +} + +.dropdown-menu.show { + background-color: var(--navbar-bg) !important; + box-shadow: 0 5px 10px var(--shadow-color); +} + +.dropdown-item { + background-color: var(--navbar-bg) !important; + color: var(--navbar-dropdown-item-color); + cursor: pointer; +} + +.dropdown-item:hover { + background-color: var(--navbar-bg) !important; + color: var(--secondary-color) !important; +} + +.disabled.text-muted { + color: var(--muted-color) !important; +} \ No newline at end of file diff --git a/src/css/Building.css b/src/css/Building.css new file mode 100644 index 0000000..73c4f46 --- /dev/null +++ b/src/css/Building.css @@ -0,0 +1,51 @@ +/* ================================ + BUILDING COMPONENT - VISUAL ONLY +================================== */ + +.building-container { + font-family: 'Product Sans', sans-serif; + color: var(--fg-color); + animation: fadeInScale 0.5s ease; +} + +.building-icon { + font-size: 4rem; + margin-bottom: 1rem; + animation: bounce 2s infinite; +} + +.building-title { + font-size: 2rem; + font-weight: bold; +} + +.building-subtitle { + font-size: 1.2rem; + margin-top: 0.5rem; + opacity: 0.75; +} + +/* Animaciones */ +@keyframes fadeInScale { + from { + opacity: 0; + transform: scale(0.95); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes bounce { + + 0%, + 100% { + transform: translateY(0px); + } + + 50% { + transform: translateY(-8px); + } +} \ No newline at end of file diff --git a/src/css/CustomCarousel.css b/src/css/CustomCarousel.css new file mode 100644 index 0000000..4e44fca --- /dev/null +++ b/src/css/CustomCarousel.css @@ -0,0 +1,11 @@ +.carousel-img-wrapper { + padding: 0.5rem; +} + +.carousel-img { + width: 100%; + height: auto; + border-radius: 1rem; + max-height: 60vh; + object-fit: cover; +} diff --git a/src/css/Footer.css b/src/css/Footer.css new file mode 100644 index 0000000..95fd13e --- /dev/null +++ b/src/css/Footer.css @@ -0,0 +1,136 @@ +.footer { + background-color: var(--navbar-bg); + color: var(--fg-color); + border-top: 2px solid var(--border-color); + font-size: 1rem; + box-shadow: 0 -2px 8px var(--shadow-color); + position: relative; + z-index: 1; +} + +.footer-title, +.footer h6#devd { + font-family: "Product Sans"; + font-size: 1.8rem; + font-weight: 700; + margin-bottom: 1rem; + color: var(--primary-color); +} + +.footer-columns { + display: flex; + flex-direction: column; + gap: 2rem; + margin-bottom: 2rem; + background-color: var(--bg-hover-color); + padding: 1.5rem; + border-radius: 1rem; + box-shadow: 0 4px 10px rgba(0,0,0,0.04); +} + +@media (min-width: 768px) { + .footer-columns { + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + } +} + +.footer-column { + flex: 1; + min-width: 200px; +} + +.footer-column h5 { + font-size: 1.1rem; + margin-bottom: 0.75rem; + font-weight: 600; + color: var(--fg-color); +} + +.footer-column ul { + list-style: none; + padding: 0; + margin: 0; +} + +.footer-column ul li { + margin-bottom: 0.5rem; +} + +.footer-column ul li a { + color: var(--fg-color); + text-decoration: none; + +} + +.footer-column ul li a:hover { + color: var(--primary-color); + text-shadow: 0 0 4px currentColor; +} + +.footer-bottom { + font-size: 0.9rem; + opacity: 0.85; + text-align: center; + border-top: 1px solid var(--divider-color); +} + +.footer-bottom a { + font-weight: 600; + text-decoration: none; + color: var(--fg-color); + +} + +.footer-bottom a:hover { + text-shadow: 0 0 5px currentColor; + color: var(--primary-color); +} + +.contact-info { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + border-radius: 1rem; + font-size: 0.95rem; + background-color: var(--contact-info-bg); + color: var(--primary-color); + box-shadow: 0 4px 10px var(--shadow-color); + +} + +.contact-info a { + font-weight: 600; + text-decoration: none; + color: var(--primary-color); + display: inline-flex; + align-items: center; + gap: 0.5rem; + +} + +.contact-info a:hover { + transform: translateX(8px); + color: var(--secondary-color); +} + +.contact-info .fa-icon { + font-size: 1.2rem; + color: var(--primary-color); +} + +.heart-anim { + display: inline-block; + animation: heartbeat 1.5s infinite ease-in-out; +} + +@keyframes heartbeat { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.15); + } +} diff --git a/src/css/Header.css b/src/css/Header.css new file mode 100644 index 0000000..3d51fa8 --- /dev/null +++ b/src/css/Header.css @@ -0,0 +1,43 @@ +/* ================================ + HEADER - ESTILO BASE +================================== */ + +.bg-img { + background-image: url('/images/bg.png'); + background-size: cover; + background-position: center; + background-repeat: no-repeat; +} + +/*.mask { + background-color: var(--header-mask-color); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + +} +*/ + +.header-title { + font-family: 'Product Sans'; + font-size: 3em; + font-weight: bolder; + color: var(--text-color); + +} + +.shadowed { + text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.5); +} + + +/* ================================ + RESPONSIVE HEADER TITLE +================================== */ + +@media (max-width: 768px) { + .header-title { + font-size: 2em; + padding: 0 1rem; + text-align: center; + } +} diff --git a/src/css/Home.css b/src/css/Home.css new file mode 100644 index 0000000..e69de29 diff --git a/src/css/LoginForm.css b/src/css/LoginForm.css new file mode 100644 index 0000000..e6d933d --- /dev/null +++ b/src/css/LoginForm.css @@ -0,0 +1,53 @@ +/* ================================ + LOGIN - CARD CONTAINER (VISUAL) +================================== */ + +.login-card { + background-color: var(--login-bg) !important; + color: var(--text-color); + box-shadow: 0 0 10px var(--shadow-color); + +} + +/* ================================ + INPUTS VISUALES + ================================== */ + +input.form-control { + background-color: var(--input-bg); + color: var(--input-text); + border: 1px solid var(--input-border); + +} + +/* ================================ + LABELS PERSONALIZADAS + ================================== */ + +.form-floating>label { + font-family: 'Product Sans'; + font-size: 1.1em; + color: var(--label-color); +} + +.form-floating>label::after { + background-color: transparent !important; +} + +/* ================================ + BOTÓN VISUAL + ================================== */ + +.login-button { + font-family: 'Product Sans' !important; + font-size: 1.3em !important; + font-weight: bold !important; + background-color: var(--login-btn-bg) !important; + color: var(--login-btn-text) !important; + +} + +.login-button:hover { + background-color: var(--login-btn-hover) !important; + color: var(--login-btn-text-hover) !important; +} \ No newline at end of file diff --git a/src/css/NavBar.css b/src/css/NavBar.css new file mode 100644 index 0000000..7379d5e --- /dev/null +++ b/src/css/NavBar.css @@ -0,0 +1,65 @@ +/* ================================ + NAVBAR - VISUAL + THEMING ONLY +================================== */ + +.navbar { + background-color: var(--navbar-bg) !important; + box-shadow: var(--navbar-shadow); + + z-index: 1000; +} + +.navbar-brand { + color: var(--navbar-brand-color) !important; + +} + +.navbar-brand:hover { + color: var(--navbar-brand-hover) !important; +} + +a.nav-link, +.nav-item > a.nav-link, +.dropdown-item { + font-family: "Product Sans"; + font-size: larger; + border-radius: 8px; + padding: 0.5rem 1rem; + + color: var(--navbar-link-color) !important; +} + +.nav-link:hover, +.nav-link:focus { + background-color: var(--navbar-link-hover-bg) !important; + color: var(--navbar-link-hover-color) !important; +} + +hr { + border-top: 1px solid var(--navbar-divider-color); + +} + +.navbar-toggler:focus, +.navbar-toggler:active, +.navbar-toggler-icon:focus { + outline: none; + box-shadow: none; +} + +/* ================================ + ANIMACIONES +================================== */ + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-5px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + diff --git a/src/css/NotificationModal.css b/src/css/NotificationModal.css new file mode 100644 index 0000000..1ccc55a --- /dev/null +++ b/src/css/NotificationModal.css @@ -0,0 +1,70 @@ +.modal-header { + display: flex !important; + justify-content: flex-start !important; + align-items: center !important; + gap: 0.75rem !important; + padding: 1rem !important; + border-radius: 1rem 1rem 0 0 !important; + background-color: var(--modal-bg) !important; + color: var(--text-color) !important; + border-bottom: none !important; +} + +.modal-content { + background-color: transparent !important; + border-radius: 1rem !important; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25) !important; +} + +button.btn-close { + color: var(--modal-close-color) !important; + filter: brightness(0.8) !important; + transition: filter 0.2s ease !important; +} +button.btn-close:hover { + filter: brightness(1) !important; +} + +.modal-body { + padding: 1.5rem !important; + font-size: 1.05rem !important; + color: var(--text-color) !important; + background-color: var(--modal-bg) !important; +} + +.modal-footer { + display: flex !important; + justify-content: flex-end !important; + align-items: center !important; + gap: 0.75rem !important; + padding: 1rem !important; + border: none !important; + border-radius: 0 0 1rem 1rem !important; + background-color: var(--modal-bg) !important; +} + +.modal-dialog button.dialog-btn, +.modal-footer button { + padding: 0.6rem 1.4rem !important; + border-radius: 0.75rem !important; + border: none !important; + font-weight: 600 !important; + transition: all 0.2s ease !important; +} + +.modal-footer button.btn-primary { + background-color: #086dd6 !important; + color: #fff !important; +} +.modal-footer button.btn-primary:hover { + background-color: #005bb5 !important; +} + +.modal-footer button.btn-secondary { + background-color: #e0e0e0 !important; + color: #222 !important; +} +.dark .modal-footer button.btn-secondary { + background-color: #444 !important; + color: #eee !important; +} diff --git a/src/css/PasswordInput.css b/src/css/PasswordInput.css new file mode 100644 index 0000000..b1fc02e --- /dev/null +++ b/src/css/PasswordInput.css @@ -0,0 +1,7 @@ +button.show-button { + color: var(--show-btn-color); + +} +button.show-button:hover { + color: var(--show-btn-hover); +} diff --git a/src/css/PasswordModal.css b/src/css/PasswordModal.css new file mode 100644 index 0000000..1c9c8e8 --- /dev/null +++ b/src/css/PasswordModal.css @@ -0,0 +1,62 @@ +.modal-header { + display: flex !important; + justify-content: space-between !important; + align-items: center !important; + padding: 1rem !important; + border-radius: 1rem 1rem 0 0 !important; + background-color: var(--modal-bg) !important; + color: var(--text-color) !important; + border-bottom: none !important; +} + +.modal-content { + background-color: transparent !important; +} + +button.btn-close { + color: var(--modal-close-color) !important; +} + +.modal-footer { + display: flex !important; + justify-content: space-between !important; + align-items: center !important; + padding: 1rem !important; + border: none !important; + border-radius: 0 0 1rem 1rem !important; + background-color: var(--modal-bg) !important; +} + +.modal-dialog button.dialog-btn { + padding: 0.6rem 1.4rem !important; + border-radius: 0.75rem !important; + background-color: #086dd6 !important; + border: none !important; + font-weight: 600 !important; + color: #fff !important; + transition: all 0.2s ease !important; +} + +.modal-dialog input { + border-radius: 0.75rem; + border: 1px solid var(--input-border, #ddd); + padding: 0.75rem 1rem; + font-size: 1.1rem !important; + background-color: var(--input-bg, rgba(255, 255, 255, 0.7)); + color: var(--input-text, #222); +} + +.dark .modal-dialog input { + background-color: var(--input-bg, #222) !important; + border: 1px solid var(--input-border, #555) !important; + color: var(--input-text, #eee) !important; +} + +.modal-dialog .form-floating > label { + color: var(--label-color, var(--text-color)) !important; +} + +.modal-dialog .form-floating > .form-control:focus ~ label, +.modal-dialog .form-floating > .form-control:not(:placeholder-shown) ~ label { + color: var(--accent-color, var(--text-color)) !important; +} diff --git a/src/css/PastePanel.css b/src/css/PastePanel.css new file mode 100644 index 0000000..c1bf9d2 --- /dev/null +++ b/src/css/PastePanel.css @@ -0,0 +1,132 @@ +/* ================================ + PANEL GENERAL +================================== */ +.paste-panel { + background: var(--bg-color, rgba(255, 255, 255, 0.8)); + color: var(--card-text, #222); +} + +/* ================================ + INPUTS, TEXTAREA Y SELECTS +================================== */ +.paste-panel input, +.paste-panel textarea, +.paste-panel select { + border-radius: 0.75rem; + border: 1px solid var(--input-border, #ddd); + padding: 0.75rem 1rem; + font-size: 1.1rem !important; + background-color: var(--input-bg, rgba(255, 255, 255, 0.7)); + color: var(--input-text, #222); +} + +/* Floating label background */ +.paste-panel .form-floating textarea ~ label::after { + background-color: var(--input-bg, rgba(255, 255, 255, 0.7)); +} +.dark .paste-panel .form-floating textarea ~ label::after { + background-color: var(--input-bg, #222); +} + +/* ================================ + BOTÓN PRINCIPAL +================================== */ +.paste-panel button[type="submit"] { + padding: 0.6rem 1.4rem; + border-radius: 0.75rem; + background-color: #086dd6; + border: none; + font-weight: 600; + color: #fff; + transition: all 0.2s ease; +} + +.paste-panel button[type="submit"]:hover { + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(8, 109, 214, 0.3); + background-color: #0a5ed6; +} + +/* ================================ + FORM-FLOATING LABEL +================================== */ +.paste-panel .form-floating > label { + color: var(--label-color, #333); +} + +/* Evitar azul por defecto en focus */ +.paste-panel .form-floating > .form-control:focus ~ label { + color: inherit; +} + +/* ================================ + FORM SWITCH / CHECK +================================== */ +.paste-panel .form-check-label { + color: var(--text-color, #222); +} + +.paste-panel .form-check-input:checked { + background-color: #086dd6; + border-color: #086dd6; +} + +.paste-panel .form-check-input:focus { + box-shadow: 0 0 0 0.2rem rgba(8, 109, 214, 0.25); +} + +/* ================================ + DARK MODE +================================== */ +.dark .paste-panel { + background: var(--bg-color, #222); + border: 1px solid rgba(255, 255, 255, 0.05); + color: var(--card-text, #eee); +} + +.dark .paste-panel input, +.dark .paste-panel textarea, +.dark .paste-panel select { + background-color: var(--input-bg, #222); + border: 1px solid var(--input-border, #555); + color: var(--input-text, #eee); +} + +.dark .paste-panel .form-floating > label { + color: var(--label-color, #eee); +} + +.dark .paste-panel .form-floating > .form-control:focus ~ label { + color: inherit; +} + +.dark .paste-panel .form-check-label { + color: var(--text-color, #fff); +} + +.dark .paste-panel .form-check-input:checked { + background-color: #086dd6; + border-color: #086dd6; +} + +.dark .paste-panel .form-check-input:focus { + box-shadow: 0 0 0 0.2rem rgba(8, 109, 214, 0.25); +} + +.dark .paste-panel button[type="submit"] { + background-color: #086dd6; +} + +.dark .paste-panel button[type="submit"]:hover { + background-color: #0a5ed6; +} + + +/* ================================ + CODE EDITOR +================================== */ + +.code-editor { + border-radius: 0.75rem; + background-color: var(--code-bg); +} \ No newline at end of file diff --git a/src/css/SearchToolbar.css b/src/css/SearchToolbar.css new file mode 100644 index 0000000..74220f9 --- /dev/null +++ b/src/css/SearchToolbar.css @@ -0,0 +1,43 @@ +/* =================== + SEARCH TOOLBAR +=================== */ +.search-toolbar { + display: flex; + align-items: center; + border-radius: 999px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + padding: 0.5rem 1rem; + background-color: var(--search-bg); + border: 1px solid var(--search-border) !important; +} + +.search-toolbar:has(input:focus) { + transform: scale(1.02); + box-shadow: 0 6px 16px var(--shadow-color); + border-color: var(--accent-color) !important; + transition: all 0.2s ease-in-out; +} + +.search-toolbar.focused { + transform: scale(1.02); + box-shadow: 0 6px 16px var(--shadow-color); + border-color: var(--accent-color) !important; + transition: all 0.2s ease-in-out; +} + +.search-toolbar input.search-input { + all: unset; + flex-grow: 1; + width: 100%; + height: 24px; + font-size: 1.1rem; + font-family: "Open Sans", sans-serif; + padding-right: 1rem; + background: transparent !important; + color: var(--search-input-color); +} + +.search-toolbar input.search-input::placeholder { + color: var(--search-placeholder); +} \ No newline at end of file diff --git a/src/css/ThemeButton.css b/src/css/ThemeButton.css new file mode 100644 index 0000000..371a267 --- /dev/null +++ b/src/css/ThemeButton.css @@ -0,0 +1,61 @@ +/* ================================ + THEME TOGGLE - BASE +================================== */ + +.theme-toggle { + width: auto; + height: 40px; + border: none; + border-radius: 999px; + display: flex; + padding: 0 1rem; + align-items: center; + justify-content: center; + cursor: pointer; + outline: none; + background-color: var(--toggle-bg); + color: var(--toggle-fg); + font-family: 'Product Sans'; + font-size: 1.2rem; + transition: + background-color 0.3s ease, + color 0.3s ease, + transform 0.2s ease, + box-shadow 0.3s ease; +} + +/* ================================ + HOVER / ACTIVE STATES + ================================== */ + +.theme-toggle:hover { + transform: scale(1.05); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.2); +} + +/* ================================ + LIGHT THEME + ================================== */ + +.light { + --toggle-bg: #1e1e1e; + --toggle-fg: #f0f0f0; +} + +.light .theme-toggle { + box-shadow: 0 0px 10px rgba(68, 7, 182, 0.808); +} + + +/* ================================ + DARK THEME + ================================== */ + +.dark { + --toggle-bg: #f0f0f0; + --toggle-fg: #1e1e1e; +} + +.dark .theme-toggle { + box-shadow: 0 0px 10px rgba(206, 180, 36, 0.589); +} \ No newline at end of file diff --git a/src/css/index.css b/src/css/index.css new file mode 100644 index 0000000..0d0111a --- /dev/null +++ b/src/css/index.css @@ -0,0 +1,272 @@ +/* ================================ + FUENTES PERSONALIZADAS +================================== */ +@font-face { + font-family: "Fira Code"; + src: url('/fonts/FiraCode.ttf'); +} + +@font-face { + font-family: "Open Sans"; + src: url('/fonts/OpenSans.ttf'); +} + +@font-face { + font-family: "Product Sans"; + src: url('/fonts/ProductSansRegular.ttf'); +} + +@font-face { + font-family: "Product Sans Italic"; + src: url('/fonts/ProductSansItalic.ttf'); +} + +@font-face { + font-family: "Product Sans Italic Bold"; + src: url('/fonts/ProductSansBoldItalic.ttf'); +} + +@font-face { + font-family: "Product Sans Bold"; + src: url('/fonts/ProductSansBold.ttf'); +} + +/* ================================ + PALETA DE COLORES +================================== */ + +:root { + --highlight-border: var(--accent-color); + --box-shadow-soft: 0 4px 6px var(--shadow-color); + --alert-bg: #f8d7da; +} + +.light { + --primary-color: #333; + --secondary-color: #555; + --tertiary-color: #777; + --border-color: #dee2e6; + --divider-color: #ddd; + --bg-color: #fff; + --fg-color: #111; + --text-color: #111; + --muted-color: #666; + --shadow-color: rgba(0, 0, 0, 0.1); + --bg-hover-color: #f0f0f0; + --bg-search-bar: rgba(0,0,0,0.05); + --input-bg: #fff; + --input-border: var(--border-color); + --placeholder-color: #999; + --input-text: var(--text-color); + --accent-color: #333; + --btn-bg: #333; + --btn-bg-hover: #555; + --btn-text: #fff; + --btn-text-hover: #fff; + --icon-color: var(--fg-color); + --highlight-border: #777; + --card-bg: #fff; + --card-button: #fff; + --card-border: var(--border-color); + --card-text: var(--text-color); + --card-text-secondary: #555; + --card-btn-hover: rgba(0,0,0,0.05); + --card-muted-text: #666; + --item-bg: #fff; + --item-text: var(--text-color); + --subtitle-color: #666; + --login-bg: #f9f9f9; + --label-color: var(--text-color); + --login-btn-bg: #333; + --login-btn-hover: #555; + --login-btn-text: #fff; + --login-btn-text-hover: #111; + --header-mask-color: rgba(0,0,0,0.1); + --navbar-bg: #fff; + --navbar-brand-color: #333; + --navbar-brand-hover: #555; + --navbar-link-color: #111; + --navbar-link-hover-bg: #f0f0f0; + --navbar-link-hover-color: #333; + --navbar-dropdown-bg: #fff; + --navbar-dropdown-item-color: #111; + --navbar-dropdown-item-hover-color: #333; + --navbar-divider-color: #ccc; + --hamburger-color: #333; + --navbar-shadow: 0 2px 6px rgba(0,0,0,0.05); + --show-btn-color: #333; + --show-btn-hover: #555; + --header-btn-hover: rgba(0,0,0,0.05); + --list-hover-bg: rgba(0,0,0,0.03); + --list-hover-bg-light: #f5f5f5; + --list-active-bg-light: #e0e0e0; + --search-bg: rgba(255,255,255,0.6); + --search-border: var(--border-color); + --search-input-color: #111; + --search-placeholder: #999; + --toolbar-btn-color: #111; + --toolbar-btn-hover: rgba(0,0,0,0.07); + --modal-bg: #fff; + --modal-header-border: var(--border-color); + --modal-body-bg: #fff; + --modal-close-color: #111; + --contact-info-bg: #f5f5f5; + --balance-report-bg: #fff; + --file-card-bg: #fff; + --sidebar-bg: #eee; + --code-bg: #fffffe; +} + +.dark { + --primary-color: #eee; + --secondary-color: #ccc; + --tertiary-color: #999; + --border-color: #555; + --divider-color: #555; + --bg-color: #111; + --fg-color: #fff; + --text-color: #fff; + --muted-color: #aaa; + --shadow-color: rgba(0,0,0,0.5); + --bg-hover-color: #1e1e1e; + --bg-search-bar: rgba(255,255,255,0.05); + --input-bg: #1e1e1e; + --input-border: var(--border-color); + --placeholder-color: #888; + --input-text: #fff; + --accent-color: #eee; + --btn-bg: #eee; + --btn-bg-hover: #ccc; + --btn-text: #111; + --btn-text-hover: #000; + --icon-color: #fff; + --highlight-border: #999; + --alert-bg: #500; + --card-bg: #1e1e1e; + --card-button: #1e1e1e; + --card-border: var(--border-color); + --card-text: #fff; + --card-text-secondary: #ccc; + --card-btn-hover: rgba(255,255,255,0.05); + --item-bg: #1e1e1e; + --item-text: #fff; + --subtitle-color: #aaa; + --login-bg: #111; + --label-color: #fff; + --login-btn-bg: #eee; + --login-btn-hover: #ccc; + --login-btn-text: #111; + --login-btn-text-hover: #000; + --header-mask-color: rgba(0,0,0,0.3); + --navbar-bg: #111; + --navbar-brand-color: #eee; + --navbar-brand-hover: #ccc; + --navbar-link-color: #fff; + --navbar-link-hover-bg: #1e1e1e; + --navbar-link-hover-color: #ccc; + --navbar-dropdown-bg: #1e1e1e; + --navbar-dropdown-item-color: #fff; + --navbar-dropdown-item-hover-color: #ccc; + --navbar-divider-color: #555; + --hamburger-color: #eee; + --navbar-shadow: 0 2px 5px rgba(0,0,0,0.5); + --show-btn-color: #eee; + --show-btn-hover: #ccc; + --card-muted-text: #aaa; + --header-btn-hover: rgba(255,255,255,0.05); + --list-hover-bg: rgba(255,255,255,0.03); + --list-hover-bg-dark: #333; + --list-active-bg-dark: #444; + --search-bg: rgba(255,255,255,0.1); + --search-border: var(--border-color); + --search-input-color: #fff; + --search-placeholder: #888; + --toolbar-btn-color: #fff; + --toolbar-btn-hover: rgba(255,255,255,0.08); + --modal-bg: #1e1e1e; + --modal-header-border: var(--border-color); + --modal-body-bg: #1e1e1e; + --modal-close-color: #fff; + --contact-info-bg: #111; + --balance-report-bg: #1e1e1e; + --file-card-bg: #1e1e1e; + --sidebar-bg: #000; + --code-bg: #1e1e1e; +} + +/* ================================ + ESTILOS BASE / RESET SUAVE +================================== */ +html, +body { + font-family: "Open Sans", sans-serif; + color: var(--text-color); + background-color: var(--bg-color); + +} + +body { + background-color: transparent !important; /* compatibilidad navbar fija */ +} + +main { + color: var(--text-color); + background-color: var(--bg-color); +} + +html, body, #root { + height: 100%; + margin: 0; + padding: 0; +} + +/* Tipografía global */ +div, +label, +input, +p, +span, +a, +button { + font-family: "Open Sans", sans-serif; + color: var(--text-color); +} + +h1, +h2, +h3, +h4, +h5, +h6 { + font-family: "Product Sans", sans-serif; + color: var(--text-color); +} + +.custom-border { + border: 1px solid var(--border-color) !important; +} + +.custom-border-top { + border-top: 1px solid var(--border-color) !important; +} + +.custom-border-bottom { + border-bottom: 1px solid var(--border-color) !important; +} + +.custom-border-left { + border-left: 1px solid var(--border-color) !important; +} + +.custom-border-right { + border-right: 1px solid var(--border-color) !important; +} + +.custom-text-muted { + color: var(--muted-color) !important; +} + +div.fill { + height: calc(100% - 71px); +} + diff --git a/src/hooks/useAuth.js b/src/hooks/useAuth.js new file mode 100644 index 0000000..ad9a9f7 --- /dev/null +++ b/src/hooks/useAuth.js @@ -0,0 +1,4 @@ +import { useContext } from "react"; +import { AuthContext } from "@/context/AuthContext"; + +export const useAuth = () => useContext(AuthContext); diff --git a/src/hooks/useConfig.js b/src/hooks/useConfig.js new file mode 100644 index 0000000..024ee84 --- /dev/null +++ b/src/hooks/useConfig.js @@ -0,0 +1,4 @@ +import { useContext } from "react"; +import { ConfigContext } from "@/context/ConfigContext.jsx"; + +export const useConfig = () => useContext(ConfigContext); diff --git a/src/hooks/useData.js b/src/hooks/useData.js new file mode 100644 index 0000000..9bc6d6e --- /dev/null +++ b/src/hooks/useData.js @@ -0,0 +1,133 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import axios from "axios"; + +export const useData = (config) => { + const [data, setData] = useState(null); + const [dataLoading, setLoading] = useState(true); + const [dataError, setError] = useState(null); + const configRef = useRef(); + + useEffect(() => { + if (config?.baseUrl) { + configRef.current = config; + } + }, [config]); + + const getAuthHeaders = () => ({ + "Content-Type": "application/json", + "Authorization": `Bearer ${localStorage.getItem("token")}`, + }); + + const fetchData = useCallback(async () => { + const current = configRef.current; + if (!current?.baseUrl) return; + + setLoading(true); + setError(null); + + try { + const response = await axios.get(current.baseUrl, { + headers: getAuthHeaders(), + params: current.params, + }); + setData(response.data.data); + } catch (err) { + setError(err.response?.data?.message || err.message); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (config?.baseUrl) { + fetchData(); + } + }, [config, fetchData]); + + const getData = async (url, params = {}, headers = {}) => { + try { + const response = await axios.get(url, { + headers: { ...getAuthHeaders(), ...headers }, + params, + }); + return { data: response.data.data, error: null }; + } 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 headers = { + 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 { + parsed = JSON.parse(raw); + } catch { + return { data: null, errors: { general: raw || err.message } }; + } + + return { data: null, errors: parsed }; + } + }; + + 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 { + data, + dataLoading, + dataError, + getData, + postData, + postDataValidated, + putData, + deleteData, + deleteDataWithBody, + }; +}; diff --git a/src/hooks/useDataContext.js b/src/hooks/useDataContext.js new file mode 100644 index 0000000..e139079 --- /dev/null +++ b/src/hooks/useDataContext.js @@ -0,0 +1,4 @@ +import { useContext } from "react"; +import { DataContext } from "@/context/DataContext"; + +export const useDataContext = () => useContext(DataContext); diff --git a/src/hooks/useSearchBar.js b/src/hooks/useSearchBar.js new file mode 100644 index 0000000..b0fb58b --- /dev/null +++ b/src/hooks/useSearchBar.js @@ -0,0 +1,22 @@ +import { useState } from "react"; + +export const useSearchBar = (initialFilters = {}) => { + const [searchTerm, setSearchTerm] = useState(""); + const [filters, setFilters] = useState(initialFilters); + + const isSearching = searchTerm.trim() !== ""; + const isFiltering = Object.keys(filters).length > 0; + + return { + searchTerm, + setSearchTerm, + filters, + setFilters, + isSearching, + isFiltering, + reset: () => { + setSearchTerm(""); + setFilters(initialFilters); + }, + }; +}; diff --git a/src/hooks/useSessionRenewal.jsx b/src/hooks/useSessionRenewal.jsx new file mode 100644 index 0000000..4566619 --- /dev/null +++ b/src/hooks/useSessionRenewal.jsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from "react"; +import { parseJwt } from "../util/tokenUtils.js"; +import NotificationModal from "../components/NotificationModal.jsx"; +import axios from "axios"; +import { useAuth } from "./useAuth.js"; +import { useConfig } from "./useConfig.js"; + +const useSessionRenewal = () => { + const { logout } = useAuth(); + const { config } = useConfig(); + + const [showModal, setShowModal] = useState(false); + const [alreadyWarned, setAlreadyWarned] = useState(false); + + useEffect(() => { + const interval = setInterval(() => { + const token = localStorage.getItem("token"); + const decoded = parseJwt(token); + + if (!token || !decoded?.exp) return; + + const now = Date.now(); + const expTime = decoded.exp * 1000; + const timeLeft = expTime - now; + + if (timeLeft <= 60000 && timeLeft > 0 && !alreadyWarned) { + setShowModal(true); + setAlreadyWarned(true); + } + + if (timeLeft <= 0) { + clearInterval(interval); + logout(); + } + }, 10000); // revisa cada 10 segundos + + return () => clearInterval(interval); + }, [alreadyWarned, logout]); + + const handleRenew = async () => { + const token = localStorage.getItem("token"); + const decoded = parseJwt(token); + const now = Date.now(); + const expTime = decoded?.exp * 1000; + + if (!token || !decoded || now > expTime) { + logout(); + return; + } + + try { + const response = await axios.get( + `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.auth.refreshToken}`, + null, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + const newToken = response.data.data.token; + localStorage.setItem("token", newToken); + setShowModal(false); + setAlreadyWarned(false); + } catch (err) { + console.error("Error renovando sesión:", err); + logout(); + } + }; + + const modal = showModal && ( + { + setShowModal(false); + logout(); + }} + title="¿Quieres seguir conectado?" + message="Tu sesión está a punto de expirar. ¿Quieres renovarla 1 hora más?" + variant="info" + buttons={[ + { + label: "Renovar sesión", + variant: "success", + onClick: handleRenew, + }, + { + label: "Cerrar sesión", + variant: "danger", + onClick: () => { + logout(); + setShowModal(false); + }, + }, + ]} + /> + ); + + return { modal }; +}; + +export default useSessionRenewal; diff --git a/src/hooks/useTheme.js b/src/hooks/useTheme.js new file mode 100644 index 0000000..026c44b --- /dev/null +++ b/src/hooks/useTheme.js @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { ThemeContext } from "../context/ThemeContext"; + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme debe usarse dentro de un "); + } + return context; +}; diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..85c0fc7 --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,33 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' + +/* COMPONENTS */ +import App from '@/App.jsx' +import { BrowserRouter } from 'react-router-dom' +import { ThemeProvider } from '@/context/ThemeContext' +import { AuthProvider } from '@/context/AuthContext' +import { ConfigProvider } from '@/context/ConfigContext.jsx' + +/* CSS */ +import 'bootstrap/dist/css/bootstrap.min.css' +import 'bootstrap/dist/js/bootstrap.bundle.min.js' +import "slick-carousel/slick/slick.css"; +import "slick-carousel/slick/slick-theme.css"; +import '@/css/index.css' +import { SearchProvider } from './context/SearchContext' + +createRoot(document.getElementById('root')).render( + + + + + + + + + + + + + +) \ No newline at end of file diff --git a/src/pages/Building.jsx b/src/pages/Building.jsx new file mode 100644 index 0000000..5a2c4dc --- /dev/null +++ b/src/pages/Building.jsx @@ -0,0 +1,17 @@ +import { useLocation } from 'react-router-dom'; +import '@/css/Building.css'; + +export default function Building() { + const location = useLocation(); + + if (location.pathname === '/') return null; + + return ( +
+
🚧
+
Esta página está en construcción
+
Estamos trabajando para traértela pronto
+
+ + ); +} diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx new file mode 100644 index 0000000..8010a77 --- /dev/null +++ b/src/pages/Home.jsx @@ -0,0 +1,81 @@ +import '@/css/Home.css'; +import PastePanel from '@/components/Pastes/PastePanel'; +import { useConfig } from '@/hooks/useConfig'; +import LoadingIcon from '@/components/LoadingIcon'; +import { useDataContext } from '@/hooks/useDataContext'; +import { useState } from 'react'; +import { DataProvider } from '@/context/DataContext'; +import NotificationModal from '@/components/NotificationModal'; +import { useSearch } from "@/context/SearchContext"; + +const Home = () => { + const { config, configLoading } = useConfig(); + + if (configLoading) return

; + + const reqConfig = { + baseUrl: `${config.apiConfig.baseUrl}${config.apiConfig.endpoints.pastes.all}`, + params: { + _sort: 'created_at', + _order: 'desc', + }, + }; + + return ( + + + + ); +}; + +const HomeContent = ({ reqConfig }) => { + const { data, dataLoading, dataError, postData } = useDataContext(); + const [error, setError] = useState(null); + const [key, setKey] = useState(null); + const { searchTerm } = useSearch(); + + if (dataLoading) return

; + if (dataError) return

Error loading data

; + + const filtered = data.filter(paste => + paste.title.toLowerCase().includes((searchTerm ?? "").toLowerCase()) || + paste.content.toLowerCase().includes((searchTerm ?? "").toLowerCase()) + ); + + const handleSubmit = async (paste) => { + try { + const createdPaste = await postData(reqConfig.baseUrl, paste); + if (createdPaste && createdPaste.is_private) { + setKey(createdPaste.paste_key); + } + } catch (error) { + setError(error); + } + }; + + return ( + <> + + setKey(null)} + title="Link a tu paste privado" + message={ + + Tu paste privado ha sido creado. Puedes acceder a él mediante el siguiente enlace: +

+ https://paste.miarma.net/${key} +

+ Recuerda que este enlace es único y no se puede recuperar si se pierde. +
+ } + variant="" + buttons={[ + { label: "Cerrar", variant: "secondary", onClick: () => setKey(null) } + ]} + /> + + ); +} + +export default Home; diff --git a/src/util/alertHelpers.jsx b/src/util/alertHelpers.jsx new file mode 100644 index 0000000..742107a --- /dev/null +++ b/src/util/alertHelpers.jsx @@ -0,0 +1,15 @@ +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 ( +
+ {typeof error === 'string' ? error : 'An unexpected error occurred.'} +
+ ); +}; + +export const resetErrorIfEditEnds = (editMode, setError) => { + if (!editMode) setError(null); +}; diff --git a/src/util/constants.js b/src/util/constants.js new file mode 100644 index 0000000..1aaf0e6 --- /dev/null +++ b/src/util/constants.js @@ -0,0 +1,7 @@ +'use strict'; + +const CONSTANTS = { + +}; + +export { CONSTANTS }; diff --git a/src/util/date.js b/src/util/date.js new file mode 100644 index 0000000..c9d9dc3 --- /dev/null +++ b/src/util/date.js @@ -0,0 +1,10 @@ +'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 } \ No newline at end of file diff --git a/src/util/parsers/dateParser.js b/src/util/parsers/dateParser.js new file mode 100644 index 0000000..dc80506 --- /dev/null +++ b/src/util/parsers/dateParser.js @@ -0,0 +1,30 @@ +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); + } +}; diff --git a/src/util/parsers/errorParser.js b/src/util/parsers/errorParser.js new file mode 100644 index 0000000..971bcd8 --- /dev/null +++ b/src/util/parsers/errorParser.js @@ -0,0 +1,10 @@ +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"; + } +}; diff --git a/src/util/passwordGenerator.js b/src/util/passwordGenerator.js new file mode 100644 index 0000000..9a99610 --- /dev/null +++ b/src/util/passwordGenerator.js @@ -0,0 +1,29 @@ +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(''); +}; diff --git a/src/util/tokenUtils.js b/src/util/tokenUtils.js new file mode 100644 index 0000000..38e5970 --- /dev/null +++ b/src/util/tokenUtils.js @@ -0,0 +1,7 @@ +export const parseJwt = (token) => { + try { + return JSON.parse(atob(token.split('.')[1])); + } catch (e) { + return null; + } +}; diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..a8506ad --- /dev/null +++ b/vite.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + host: "localhost", + port: 3000, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +})