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 (
+
+
+
+
+
+ );
+};
+
+
+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) => (
+
+

+
+ ))}
+
+
+ );
+};
+
+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."
+ }
+
+
+ }
+
+
+
+ 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'),
+ },
+ },
+})