revamped: whole UI

This commit is contained in:
2026-03-18 18:41:04 +01:00
parent 1d08d197dc
commit 1cf10672fa
44 changed files with 655 additions and 542 deletions

View File

@@ -2,9 +2,8 @@
<html lang="es"> <html lang="es">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Jose.portfolio()</title> <title>Jose::portfolio()</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

71
package-lock.json generated
View File

@@ -17,7 +17,8 @@
"framer-motion": "^12.4.7", "framer-motion": "^12.4.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.2.0" "react-router-dom": "^7.2.0",
"twemoji": "^14.0.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
@@ -2726,6 +2727,29 @@
} }
} }
}, },
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs-extra/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2905,6 +2929,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/has-bigints": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -3529,6 +3559,18 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/jsonfile": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
"integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
"license": "MIT",
"dependencies": {
"universalify": "^0.1.2"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jsx-ast-utils": { "node_modules/jsx-ast-utils": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -4823,6 +4865,24 @@
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/twemoji": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz",
"integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==",
"license": "MIT",
"dependencies": {
"fs-extra": "^8.0.1",
"jsonfile": "^5.0.0",
"twemoji-parser": "14.0.0",
"universalify": "^0.1.2"
}
},
"node_modules/twemoji-parser": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
"integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==",
"license": "MIT"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -4933,6 +4993,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",

View File

@@ -19,7 +19,8 @@
"framer-motion": "^12.4.7", "framer-motion": "^12.4.7",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.2.0" "react-router-dom": "^7.2.0",
"twemoji": "^14.0.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

BIN
public/cat.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/fonts/FiraCode.ttf Normal file

Binary file not shown.

BIN
public/fonts/OpenSans.ttf Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +1,4 @@
import '@/css/App.css' import '@/css/index.css'
import 'bootstrap/dist/css/bootstrap.min.css' import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.bundle.min.js' import 'bootstrap/dist/js/bootstrap.bundle.min.js'
@@ -10,6 +10,8 @@ import Link from '@/components/Link.jsx'
import NavBar from '@/components/NavBar.jsx' import NavBar from '@/components/NavBar.jsx'
import Footer from '@/components/Footer.jsx' import Footer from '@/components/Footer.jsx'
import { Routes, Route } from 'react-router-dom' import { Routes, Route } from 'react-router-dom'
import HackerCat from '@/components/HackerCat'
import { useEffect } from 'react'
const App = () => { const App = () => {
@@ -23,6 +25,32 @@ const App = () => {
return delta + (septemberOrLater ? 1 : 0); return delta + (septemberOrLater ? 1 : 0);
}; };
useEffect(() => {
const setRandomFavicon = () => {
const start = 0x1F300;
const end = 0x1F64F;
const randomCode = Math.floor(Math.random() * (end - start + 1)) + start;
const hexCode = randomCode.toString(16);
const twemojiUrl = `https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/${hexCode}.svg`;
let link = document.querySelector("link[rel~='icon']");
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = twemojiUrl;
link.type = 'image/x-icon';
};
setRandomFavicon();
}, []);
return ( return (
<> <>
<Header title="Hola, soy Jose" subtitle={`Estudiante de Ingeniería de Computadores en la US (${getCurrentYear()}º año)`} /> <Header title="Hola, soy Jose" subtitle={`Estudiante de Ingeniería de Computadores en la US (${getCurrentYear()}º año)`} />
@@ -35,6 +63,8 @@ const App = () => {
<Route path="/proyectos" element={<Projects />} /> <Route path="/proyectos" element={<Projects />} />
</Routes> </Routes>
<Footer /> <Footer />
<HackerCat />
</> </>
); );
} }

View File

@@ -3,7 +3,7 @@ import { Link as RouterLink, useInRouterContext } from 'react-router-dom';
const AbstractLink = ({ to, children, className = '', ...props }) => { const AbstractLink = ({ to, children, className = '', ...props }) => {
const isInternal = to.startsWith('/'); const isInternal = to.startsWith('/');
const isRouterAvailable = useInRouterContext(); // verifica si estamos dentro de un Router const isRouterAvailable = useInRouterContext();
if (isInternal && isRouterAvailable) { if (isInternal && isRouterAvailable) {
return ( return (

View File

@@ -1,19 +1,17 @@
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import "../css/Card.css"; import "@/css/Card.css";
import { useTheme } from "../contexts/ThemeContext";
const Card = ({ title, status, children, styleMode, className, titleIcon }) => { const Card = ({ title, status, children, styleMode, className, titleIcon }) => {
const cardRef = useRef(null); const cardRef = useRef(null);
const [shortTitle, setShortTitle] = useState(title); const [shortTitle, setShortTitle] = useState(title);
const { theme } = useTheme();
useEffect(() => { useEffect(() => {
const checkSize = () => { const checkSize = () => {
if (cardRef.current) { if (cardRef.current) {
const width = cardRef.current.offsetWidth; const width = cardRef.current.offsetWidth;
if (width < 300 && title.length > 15) { if (width < 300 && title.length > 15) {
setShortTitle(title.slice(0, 10) + "."); setShortTitle(title.slice(0, 20) + ".");
} else { } else {
setShortTitle(title); setShortTitle(title);
} }
@@ -31,7 +29,7 @@ const Card = ({ title, status, children, styleMode, className, titleIcon }) => {
className={styleMode === "override" ? `${className}` : className={styleMode === "override" ? `${className}` :
`col-xl-3 col-sm-6 d-flex flex-column align-items-center p-3 card-container ${className}`} `col-xl-3 col-sm-6 d-flex flex-column align-items-center p-3 card-container ${className}`}
> >
<div className={`card p-3 w-100 ${theme}`}> <div className={`card p-3 w-100`}>
<h3 className="text-center"> <h3 className="text-center">
{titleIcon} {titleIcon}
{shortTitle} {shortTitle}

View File

@@ -1,27 +0,0 @@
import Card from "./Card.jsx";
import PropTypes from "prop-types";
const CardContainer = ({ cards, className }) => {
return (
<div className={`row justify-content-center g-0 ${className}`}>
{cards.map((card, index) => (
<Card key={index} title={card.title} status={card.status} styleMode={card.styleMode} className={card.className} titleIcon={card.titleIcon}>
<p className="card-text text-center">{card.content}</p>
</Card>
))}
</div>
);
};
CardContainer.propTypes = {
cards: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
content: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
})
).isRequired,
className: PropTypes.string,
};
export default CardContainer;

View File

@@ -1,16 +0,0 @@
import PropTypes from "prop-types";
const Container = ({ children, fluid }) => {
return (
<div className={`${fluid ? "container-fluid" : "container"}`}>
{children}
</div>
);
}
Container.propTypes = {
children: PropTypes.node.isRequired,
fluid: PropTypes.bool,
}
export default Container;

View File

@@ -1,37 +1,42 @@
import Link from './Link'; import Link from '@/components/Link';
import '../css/Footer.css'; import '@/css/Footer.css';
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
const Footer = () => { const Footer = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
const hearts = ["❤️", "💛", "🧡", "💚", "💙", "💜"]; const hearts = ["❤️", "💛", "🧡", "💚", "💙", "💜"];
const [heart, setHeart] = useState("💜"); const [heart, setHeart] = useState("💜");
const randomHeart = useCallback(() => {
return hearts[Math.floor(Math.random() * hearts.length)];
}, [hearts]);
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setHeart(randomHeart()); setHeart(randomHeart());
}, 1000); }, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}); }, [randomHeart]);
const randomHeart = () => hearts[Math.floor(Math.random() * hearts.length)];
return ( return (
<footer className="py-4 d-flex text-center justify-content-center bg-dark text-white"> <footer className="main-footer">
<p className='m-0 p-0'>Dev&apos;d with {heart} by Gallardo7761</p> <div className="container footer-container">
<span className="mx-3">|</span> <p className="footer-dev-text">
<div className="d-flex gap-3 justify-content-center"> Dev&apos;d with <span className="heart-icon">{heart}</span> by Gallardo7761
{[ </p>
{ text: 'MiarmaGit', to: 'https://git.miarma.net/Gallardo7761' },
{ text: 'Instagram', to: 'https://instagram.com/gallardoo7761' }, <span className="footer-divider">|</span>
{ text: 'Reddit', to: 'https://reddit.com/u/Gallardo7761' },
].map((social) => ( <ul className="footer-links">
<Link key={social.text} to={social.to}> <Link to="https://git.miarma.net/Gallardo7761">MiarmaGit</Link>
{social.text} <Link to="https://instagram.com/gallardoo7761">Instagram</Link>
</Link> <Link to="https://reddit.com/u/Gallardo7761">Reddit</Link>
))} <li className="nav-item">
<li className="nav-item" style={{ listStyleType: "none" }}> <a className="mastodon-verify" rel="noopener noreferer" target='_blank' href="https://masto.es/@gallardo7761">
<a className='mastodon-verify' rel="me" href="https://mastodon.social/@gallardo7761">Mastodon</a> Mastodon
</li> </a>
</li>
</ul>
</div> </div>
</footer> </footer>
); );

View File

@@ -0,0 +1,89 @@
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
const HackerCat = () => {
const [pos, setPos] = useState({ x: -100, y: 500 });
const [command, setCommand] = useState("");
const [isVisible, setIsVisible] = useState(false);
const linuxCommands = [
"sudo rm -rf /",
"apt update",
"git push --force",
"kill -9 -1",
"shutdown -h now",
"ping 8.8.8.8",
"whoami"
];
useEffect(() => {
const spawnCat = () => {
if (Math.random() > 0.4) return;
const windowH = window.innerHeight;
const randomY = Math.floor(Math.random() * (windowH - 200)) + 50;
const randomCmd = linuxCommands[Math.floor(Math.random() * linuxCommands.length)];
setPos({ x: -200, y: randomY });
setCommand(randomCmd);
setIsVisible(true);
setTimeout(() => setIsVisible(false), 11000);
};
const timer = setInterval(spawnCat, 300000);
return () => clearInterval(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!isVisible) return null;
return (
<motion.div
initial={{ x: pos.x, y: pos.y }}
animate={{
x: [pos.x, window.innerWidth + 200],
y: [pos.y, pos.y - 50, pos.y + 50, pos.y]
}}
transition={{
duration: 10,
ease: "linear",
y: { duration: 2, repeat: 5, ease: "easeInOut" }
}}
style={{
position: 'fixed',
top: 0,
left: 0,
zIndex: 9999,
pointerEvents: 'none',
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<div style={{
background: '#1e1e1e',
color: 'var(--text-color)',
padding: '5px 10px',
borderRadius: '8px',
fontSize: '14px',
fontFamily: "'Fira Code', monospace",
border: '1px solid var(--primary-color)',
marginBottom: '5px',
boxShadow: '0 0 10px var(--secondary-color)',
whiteSpace: 'nowrap'
}}>
<span style={{ color: '#ffffff' }}>$</span> {command}
</div>
<img
src="/cat.gif"
alt="Hacker Cat"
style={{ width: '100px', filter: 'drop-shadow(0 0 5px rgba(0,0,0,0.5))' }}
/>
</motion.div>
);
};
export default HackerCat;

View File

@@ -1,7 +1,7 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import "../css/Header.css"; import "@/css/Header.css";
const Header = ({ subtitle }) => { const Header = ({ subtitle }) => {
const names = useMemo(() => ["Jose", "Gallardo7761"], []); const names = useMemo(() => ["Jose", "Gallardo7761"], []);
@@ -14,6 +14,7 @@ const Header = ({ subtitle }) => {
const baseText = "Hola, soy "; const baseText = "Hola, soy ";
const fullText = baseText + (isJose ? names[0] : names[1]); const fullText = baseText + (isJose ? names[0] : names[1]);
// FSM ahh
if (animationState === "writing") { if (animationState === "writing") {
if (text.length < fullText.length) { if (text.length < fullText.length) {
timeout = setTimeout(() => { timeout = setTimeout(() => {
@@ -36,15 +37,7 @@ const Header = ({ subtitle }) => {
} }
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [text, animationState, isJose, names]); // -> Funciona !!! --> edit 08/25: fue mi primera pagina en react, se nota XD }, [text, animationState, isJose, names]);
const subtitleWithCode = (
<code>
printf(&quot;%s&quot;, &quot;{subtitle}&quot;);
</code>
);
const [isHovered, setIsHovered] = useState(false);
return ( return (
<header className="header"> <header className="header">
@@ -62,10 +55,8 @@ const Header = ({ subtitle }) => {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 0.2 }} transition={{ duration: 0.8, delay: 0.2 }}
onHoverStart={() => setIsHovered(true)}
onHoverEnd={() => setIsHovered(false)}
> >
{isHovered ? subtitleWithCode : subtitle} {subtitle}
</motion.p> </motion.p>
</div> </div>
</header> </header>

View File

@@ -1,5 +1,5 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import AbstractLink from './AbstractLink'; import AbstractLink from '@/components/AbstractLink';
const Link = ({ to, children, isNavbar, className = '', ...props }) => { const Link = ({ to, children, isNavbar, className = '', ...props }) => {
return ( return (

View File

@@ -1,6 +1,6 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import '../css/NavBar.css'; import '@/css/NavBar.css';
const NavBar = ({ children }) => { const NavBar = ({ children }) => {
const navVariants = { const navVariants = {
@@ -20,7 +20,9 @@ const NavBar = ({ children }) => {
aria-expanded="false" aria-expanded="false"
aria-label="Toggle navigation" aria-label="Toggle navigation"
> >
<span className="navbar-toggler-icon"></span> <span className="navbar-toggler-icon">
<span className="navbar-toggler-icon-custom-line"></span>
</span>
</button> </button>
<motion.div <motion.div
className="collapse navbar-collapse" className="collapse navbar-collapse"

View File

@@ -1,12 +0,0 @@
import { useTheme } from "../contexts/ThemeContext.jsx";
import "../css/ThemeButton.css";
export default function ThemeButton() {
const { theme, toggleTheme } = useTheme();
return (
<button className="theme-toggle" onClick={toggleTheme}>
{theme === "dark" ? "☀️" : "🌙"}
</button>
);
}

View File

@@ -1,60 +0,0 @@
import { createContext, useContext, useState, useEffect } from "react";
import PropTypes from "prop-types";
/**
* ConfigContext.jsx
*
* Este archivo define el contexto de configuración para la aplicación, permitiendo cargar y manejar la configuración desde un archivo externo.
*
* Importaciones:
* - createContext, useContext, useState, useEffect: Funciones de React para crear y utilizar contextos, manejar estados y efectos secundarios.
* - PropTypes: Librería para la validación de tipos de propiedades en componentes de React.
*
* Funcionalidad:
* - ConfigContext: Contexto que almacena la configuración cargada, el estado de carga y cualquier error ocurrido durante la carga de la configuración.
* - ConfigProvider: Proveedor de contexto que maneja la carga de la configuración y proporciona el estado de la configuración a los componentes hijos.
* - Utiliza `fetch` para cargar la configuración desde un archivo JSON.
* - Maneja el estado de carga y errores durante la carga de la configuración.
* - useConfig: Hook personalizado para acceder al contexto de configuración.
*
* PropTypes:
* - ConfigProvider espera un único hijo (`children`) que es requerido y debe ser un nodo de React.
*
*/
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 = await fetch("/config/settings.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 (
<ConfigContext.Provider value={{ config, configLoading, configError }}>
{children}
</ConfigContext.Provider>
);
};
ConfigProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export const useConfig = () => useContext(ConfigContext);

View File

@@ -1,67 +0,0 @@
import { createContext, useContext, useState, useEffect } from "react";
import PropTypes from "prop-types";
/**
* DataContext.jsx
*
* Este archivo define el contexto de datos para la aplicación, permitiendo obtener y manejar datos de una fuente externa.
*
* Importaciones:
* - createContext, useContext, useState, useEffect: Funciones de React para crear y utilizar contextos, manejar estados y efectos secundarios.
* - PropTypes: Librería para la validación de tipos de propiedades en componentes de React.
*
* Funcionalidad:
* - DataContext: Contexto que almacena los datos obtenidos, el estado de carga y cualquier error ocurrido durante la obtención de datos.
* - DataProvider: Proveedor de contexto que maneja la obtención de datos y proporciona el estado de los datos a los componentes hijos.
* - Utiliza `fetch` para obtener datos de una URL construida a partir de la configuración proporcionada.
* - Maneja el estado de carga y errores durante la obtención de datos.
* - useData: Hook personalizado para acceder al contexto de datos.
*
* PropTypes:
* - DataProvider espera un único hijo (`children`) que es requerido y debe ser un nodo de React.
* - DataProvider también espera una configuración (`config`) que debe incluir `baseUrl` (string) y opcionalmente `params` (objeto).
*
*/
const DataContext = createContext();
export const DataProvider = ({ children, config }) => {
const [data, setData] = useState(null);
const [dataLoading, setLoading] = useState(true);
const [dataError, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
const queryParams = new URLSearchParams(config.params).toString();
const url = `${config.baseUrl}?${queryParams}`;
const response = await fetch(url);
if (!response.ok) throw new Error("Error al obtener datos");
const result = await response.json();
setData(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [config]);
return (
<DataContext.Provider value={{ data, dataLoading, dataError }}>
{children}
</DataContext.Provider>
);
};
DataProvider.propTypes = {
children: PropTypes.node.isRequired,
config: PropTypes.shape({
baseUrl: PropTypes.string.isRequired,
params: PropTypes.object,
}).isRequired,
};
export const useData = () => useContext(DataContext);

View File

@@ -1,55 +0,0 @@
import { createContext, useContext, useEffect, useState } from "react";
import PropTypes from "prop-types";
/**
* ThemeContext.jsx
*
* Este archivo define el contexto de tema para la aplicación, permitiendo cambiar entre temas claro y oscuro.
*
* Importaciones:
* - createContext, useContext, useEffect, useState: Funciones de React para crear y utilizar contextos, manejar efectos secundarios y estados.
* - PropTypes: Librería para la validación de tipos de propiedades en componentes de React.
*
* Funcionalidad:
* - ThemeContext: Contexto que almacena el tema actual y la función para cambiarlo.
* - ThemeProvider: Proveedor de contexto que maneja el estado del tema y proporciona la función para alternar entre temas.
* - Utiliza `localStorage` para persistir el tema seleccionado.
* - Aplica la clase correspondiente al `body` del documento para reflejar el tema actual.
* - useTheme: Hook personalizado para acceder al contexto del tema.
*
* PropTypes:
* - ThemeProvider espera un único hijo (`children`) que es requerido y debe ser un nodo de React.
*
*/
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
return localStorage.getItem("theme") || "light";
});
useEffect(() => {
document.body.classList.remove("light", "dark");
document.body.classList.add(theme);
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
ThemeProvider.propTypes = {
children: PropTypes.node.isRequired,
};
export function useTheme() {
return useContext(ThemeContext);
}

View File

@@ -1,14 +0,0 @@
body {
font-family: 'Poppins', sans-serif;
min-height: 100vh;
}
body.light {
background: white;
color: black;
}
body.dark {
background: linear-gradient(135deg, var(--gradient-primary), var(--gradient-secondary));
color: white;
}

View File

@@ -1,61 +1,50 @@
.card-container {
perspective: 1000px;
}
.card { .card {
border-radius: 20px; border-radius: 16px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3); transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
transition: transform 0.3s ease, box-shadow 0.3s ease; border: 1px solid rgba(255, 63, 31, 0.3);
border: 2px solid var(--primary-color); overflow: hidden;
} display: flex;
flex-direction: column;
.card.light { justify-content: space-between;
background: linear-gradient(145deg, #eeeeee, #dadada); height: 100%;
} background: linear-gradient(145deg, #1e1e1e, #252525);
.card.light > div.card-content > p.card-text {
color: black;
}
.card.light > span.status {
background: #E0E0E0;
}
.card.dark {
background: linear-gradient(145deg, var(--card-gradient-primary), var(--card-gradient-secondary));
}
.card.dark > div.card-content > p.card-text {
color: white;
}
.card.dark > span.status {
background: #505050;
} }
.card:hover { .card:hover {
transform: translateY(-8px) scale(1.02); transform: translateY(-10px) rotateX(2deg);
box-shadow: 0 10px 20px var(--box-shadow); box-shadow: 0 15px 30px rgba(255, 63, 31, 0.3);
border-color: var(--primary-color);
} }
.card>h3 { .card h3 {
font-size: 1.3em; font-size: 1.1em;
font-family: 'Product Sans Bold', sans-serif;
color: var(--primary-color); color: var(--primary-color);
margin-bottom: 15px;
}
.card-content {
font-size: 0.95em;
font-family: 'Open Sans', sans-serif;
line-height: 1.5;
flex-grow: 1;
color: var(--text-color);
}
.card-text {
color: var(--text-color) !important;
}
.status {
font-family: 'Fira Code', monospace;
font-size: 0.75em;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
font-weight: 600; font-weight: bold;
} border: 1px solid rgba(255, 63, 31, 0.4);
color: var(--text-color);
.card > h3 > .svg-inline--fa {
margin-right: 10px;
}
p.card-text {
font-size: 2.2em;
font-weight: 600;
}
.card>span.status {
font-size: 0.9em;
color: #A0A0A0;
padding: 5px 10px;
background: var(--card-background);
border-radius: 20px;
display: inline-block;
} }

View File

@@ -1,15 +1,57 @@
footer > p > a { .main-footer {
color: var(--primary-color); background-color: #141414;
border-top: 1px solid #343a40;
padding: 2rem 0;
color: #adb5bd;
} }
footer > p > a:hover { .footer-container {
color: var(--secondary-color); display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
} }
footer > div.d-flex > li.nav-item > a { @media (min-width: 768px) {
color: var(--primary-color); .footer-container {
flex-direction: row;
justify-content: center;
}
} }
footer > div.d-flex > li.nav-item > a:hover { .footer-dev-text {
color: var(--secondary-color); margin: 0;
font-weight: 500;
}
.footer-divider {
color: #495057;
display: none;
}
@media (min-width: 768px) {
.footer-divider {
display: inline;
}
}
.footer-links {
display: flex;
gap: 1.5rem;
list-style: none;
padding: 0;
margin: 0;
}
/* Estilo para todos los enlaces del footer */
.footer-links a,
.footer-links .link-component {
color: var(--primary-color, #00d1b2);
text-decoration: none;
transition: color 0.3s ease;
font-size: 0.95rem;
}
.footer-links a:hover {
color: var(--secondary-color, #fff);
} }

View File

@@ -1,20 +1,40 @@
/* src/css/Header.css */
.header { .header {
padding: 1rem 0; /* py-4 */ padding: 4rem 0;
background-color: #212529; /* bg-dark */ background-color: #141414;
color: #fff; /* text-white */ color: #fff;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); /* shadow-lg */ text-align: center;
overflow: hidden;
} }
.header-title { .header-title {
font-size: 2.5rem; /* display-5 */ font-family: 'Fira Code';
font-weight: 700; /* fw-bold */ font-size: clamp(1.6rem, 7vw, 3.5rem);
margin-bottom: 1rem; /* mb-4 */ font-weight: 800;
color: var(--primary-color); /* Color azul de Bootstrap */ margin-bottom: 0.5rem;
color: var(--primary-color);
white-space: nowrap;
min-height: 1.2em;
display: flex;
justify-content: center;
align-items: center;
}
.header-title::after {
content: "_";
margin-left: 5px;
color: var(--primary-color);
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
} }
.header-subtitle { .header-subtitle {
font-size: 1.25rem; /* lead */ font-size: clamp(1rem, 3vw, 1.25rem);
color: #6c757d; /* text-muted */ font-family: 'Fira Code';
color: #adb5bd;
max-width: 600px;
margin: 0 auto;
} }

57
src/css/Home.css Normal file
View File

@@ -0,0 +1,57 @@
.home-container {
background: linear-gradient(135deg, var(--gradient-primary), var(--gradient-secondary));
color: white;
min-height: 100vh;
}
.glass-card {
background: rgba(255, 63, 31, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 63, 31, 0.2);
border-radius: 15px;
transition: all 0.3s ease;
}
.glass-card:hover {
border-color: var(--primary-color);
box-shadow: 0 8px 32px 0 rgba(255, 63, 31, 0.2);
transform: translateY(-5px);
}
.skill-badge {
background: rgba(255, 63, 31, 0.1) !important;
border: 1px solid var(--primary-color) !important;
color: white !important;
font-family: "Fira Code", monospace;
}
.section-title {
font-family: "Product Sans Bold", sans-serif;
letter-spacing: 2px;
color: var(--primary-color);
position: relative;
display: inline-block;
margin-bottom: 2rem;
}
.section-title::after {
content: '';
position: absolute;
bottom: -10px;
left: 0;
width: 50px;
height: 3px;
background: var(--primary-color);
}
.project-link {
color: white;
text-decoration: none;
}
.interest-item {
background: rgba(255, 255, 255, 0.03);
border-left: 3px solid var(--primary-color);
padding: 1rem;
font-family: "Product Sans", sans-serif;
}

View File

@@ -9,5 +9,40 @@
} }
nav.navbar { nav.navbar {
background-color: var(--gradient-primary); background-color: rgb(40, 40, 40);
border-top: 1px solid #343a40;
border-bottom: 1px solid #343a40;
} }
.navbar-toggler {
border: none !important;
padding: 0.5rem;
outline: none !important;
box-shadow: none !important;
}
.navbar-toggler-icon {
background-image: none !important;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
width: 30px;
height: 24px;
}
.navbar-toggler-icon::before,
.navbar-toggler-icon::after,
.navbar-toggler-icon-custom-line {
content: "";
display: block;
width: 100%;
height: 3px;
background-color: var(--primary-color);
border-radius: 3px;
transition: all 0.3s ease;
}
.navbar-toggler-icon::before { transform: translateY(-8px); }
.navbar-toggler-icon::after { transform: translateY(8px); }

5
src/css/Projects.css Normal file
View File

@@ -0,0 +1,5 @@
.projects-container {
background: linear-gradient(135deg, var(--gradient-primary), var(--gradient-secondary)) !important;
color: white !important;
min-height: 100vh !important;
}

View File

@@ -4,66 +4,55 @@
--text-shadow: #771e0e; --text-shadow: #771e0e;
--box-shadow: #a81900; --box-shadow: #a81900;
--text-color: white;
--gradient-primary: #1A1A1A; --gradient-primary: #1A1A1A;
--gradient-secondary: #2A2A2A; --gradient-secondary: #2A2A2A;
--card-background: #ff3f1f1a;
--card-gradient-primary: #252525;
--card-gradient-secondary: #353535;
} }
.badge { .primary {
background-color: var(--secondary-color) !important;
color: var(--primary-color) !important;
background-color: rgba(255, 63, 31, 0.1) !important;
}
h2.h3 {
text-transform: uppercase;
}
.mastodon-verify {
color: var(--primary-color); color: var(--primary-color);
} }
html,
/* latin-ext */ body {
@font-face { font-family: "Open Sans";
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiEyp8kv8JHgFVrJJnecmNE.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ .custom-hr {
@font-face { height: 1px;
font-family: 'Poppins'; border: none;
font-style: normal; background: linear-gradient(to right, transparent, var(--primary-color), transparent);
font-weight: 400; opacity: 0.4;
font-display: swap; margin: 4rem 0;
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiEyp8kv8JHgFVrJJfecg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
/* latin-ext */
@font-face { @font-face {
font-family: 'Poppins'; font-family: "Fira Code";
font-style: normal; src: url('/fonts/FiraCode.ttf');
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiByp8kv8JHgFVrLEj6Z1JlFc-K.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */
@font-face { @font-face {
font-family: 'Poppins'; font-family: "Open Sans";
font-style: normal; src: url('/fonts/OpenSans.ttf');
font-weight: 600;
font-display: swap;
src: url(https://fonts.gstatic.com/s/poppins/v22/pxiByp8kv8JHgFVrLEj6Z1xlFQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
@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');
}

View File

@@ -1,11 +1,15 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import '@/css/Home.css';
import Card from '@/components/Card';
const Home = () => { const Home = () => {
return ( return (
<div className="min-vh-100" style={{ background: 'linear-gradient(to bottom right, #f8f9fa, #dee2e6)' }}> <div className="home-container">
<main className="container-fluid"> <main className="container py-5">
<AboutSection /> <AboutSection />
<hr className="custom-hr" />
<ProjectsSection /> <ProjectsSection />
<hr className="custom-hr" />
<InterestsSection /> <InterestsSection />
</main> </main>
</div> </div>
@@ -14,111 +18,122 @@ const Home = () => {
const AboutSection = () => { const AboutSection = () => {
return ( return (
<section className="py-5 bg-light"> <section className="py-4">
<div className="container"> <div className="row align-items-center">
<div className="row align-items-center"> <motion.div
<motion.div className="col-md-5 mb-4 mb-md-0 d-flex justify-content-center"
className="col-md-6 mb-4 mb-md-0" initial={{ opacity: 0, scale: 0.8 }}
initial={{ opacity: 0, x: -50 }} whileInView={{ opacity: 1, scale: 1 }}
whileInView={{ opacity: 1, x: 0 }} transition={{ duration: 0.8 }}
transition={{ duration: 0.6 }} viewport={{ once: true }}
viewport={{ once: true }} >
> <div className="position-relative">
<div className="rounded-circle overflow-hidden mx-auto shadow-lg" style={{ width: '300px', height: '300px' }}> <div className="position-absolute top-50 start-50 translate-middle rounded-circle"
style={{ width: '300px', height: '300px', background: 'var(--primary-color)', filter: 'blur(60px)', opacity: 0.15 }}></div>
<div className="rounded-circle overflow-hidden shadow-lg border border-3 border-danger" style={{ width: '280px', height: '280px' }}>
<motion.img <motion.img
src="https://git.miarma.net/avatars/2fba8e2d4e39fffec3bbfe128df0cb9934ccf9b49fd6d310244c6b6209739425?size=512" src="https://git.miarma.net/avatars/2fba8e2d4e39fffec3bbfe128df0cb9934ccf9b49fd6d310244c6b6209739425?size=512"
alt="Avatar" alt="Avatar"
className="w-100 h-100 object-cover" className="w-100 h-100 object-cover"
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.1, rotate: 5 }}
transition={{ duration: 0.3 }}
/> />
</div> </div>
</motion.div> </div>
<motion.div </motion.div>
className="col-md-6"
initial={{ opacity: 0, x: 50 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
>
<h2 className="h3 fw-bold mb-3">Sobre </h2>
<p className="text-muted mb-4">
Soy un estudiante de Ingeniería de Computadores en la Universidad de Sevilla, en mi cuarto año.
Actualmente me dedico a linux, redes, microcontroladores y webdev. Por aquí abajo dejo con lo que suelo trastear:
</p>
<div className="d-flex flex-wrap gap-2">
{['C', 'Rust', 'Java', 'JS (Vanilla + React)', 'Python', 'DB', 'Linux', 'HDL'].map((skill) => (
<motion.span
key={skill}
className="badge px-3 py-2"
whileHover={{ scale: 1.1 }}
transition={{ duration: 0.2 }}
>
{skill}
</motion.span>
))}
</div>
</motion.div>
</div>
</div>
</section>
);
};
const InterestsSection = () => { <motion.div
return ( className="col-md-7 mt-4 mt-md-0"
<section className="py-5 bg-light"> initial={{ opacity: 0, x: 30 }}
<div className="container"> whileInView={{ opacity: 1, x: 0 }}
<h2 className="h3 fw-bold mb-4 text-center">Intereses</h2> transition={{ duration: 0.6 }}
<div className="row g-3"> viewport={{ once: true }}
{['Informática', 'Electrónica', 'Videojuegos', 'Música', 'Animanga', 'Ciberseguridad'].map((interest, index) => ( >
<motion.div <h2 className="section-title">SOBRE </h2>
key={interest} <p className="lead opacity-75 mb-4">
className="col-md-4 col-6" Estudiante de 4º de <strong>Ingeniería de Computadores</strong> en la US.
initial={{ opacity: 0, scale: 0.9 }} Suelo trastear con Linux, redes, electrónica y webdev.
whileInView={{ opacity: 1, scale: 1 }} </p>
transition={{ duration: 0.5, delay: index * 0.1 }} <div className="d-flex flex-wrap gap-2">
viewport={{ once: true }} {['C', 'Rust', 'Java', 'JS', 'React', 'Python', 'Linux', 'HDL'].map((skill) => (
> <motion.span
<div className="text-center p-3 bg-white rounded shadow"> key={skill}
{interest} className="badge skill-badge px-3 py-2"
</div> whileHover={{ y: -3, backgroundColor: 'var(--primary-color)', color: '#000' }}
</motion.div> >
))} {skill}
</div> </motion.span>
))}
</div>
</motion.div>
</div> </div>
</section> </section>
); );
}; };
const ProjectsSection = () => { const ProjectsSection = () => {
const projects = [
{ title: 'miarma-backend', desc: 'Backend Spring que alimenta todos mis servicios', tech: 'Java/Spring' },
{ title: 'riscv-ac', desc: 'Implementación HDL del procesador RISC-V. Arquitectura pura.', tech: 'Verilog/VHDL' },
{ title: 'contaminus', desc: 'Proyecto Hack4Change 24/25. Fullstack + IoT.', tech: 'React/Python/IoT' },
];
return ( return (
<section className="py-5"> <section>
<div className="container"> <h2 className="section-title">MIS PROYECTOS FAVORITOS</h2>
<h2 className="h3 fw-bold mb-4 text-center">Mis Proyectos Favoritos</h2> <div className="row g-4">
<div className="row g-4"> {projects.map((project, index) => (
{[ <motion.div
{ title: 'miarma-backend', desc: 'Backend Spring que alimenta todos mis servicios' }, key={project.title}
{ title: 'riscv-ac', desc: 'Implementación HDL del procesador RISC-V de la asignatura AC' }, className="col-md-4 d-flex"
{ title: 'contaminus', desc: 'Proyecto presentado al hackathon Hack4Change 24/25 de la ETSII (aunque el jurado no nos echase mucha cuenta...). Mezcla frontend, backend e IoT.' }, initial={{ opacity: 0, y: 20 }}
].map((project, index) => ( whileInView={{ opacity: 1, y: 0 }}
<motion.div transition={{ delay: index * 0.1 }}
key={project.title} viewport={{ once: true }}
className="col-md-4" >
initial={{ opacity: 0, y: 30 }} <a
whileInView={{ opacity: 1, y: 0 }} href={`https://git.miarma.net/Gallardo7761/${project.title}`}
transition={{ duration: 0.6, delay: index * 0.2 }} className="text-decoration-none w-100 d-flex"
viewport={{ once: true }} target="_blank"
rel="noopener noreferrer"
> >
<div className="card h-100 shadow-sm"> <Card
<div className="card-body"> title={project.title}
<h5 className="card-title">{project.title}</h5> status={project.tech}
<p className="card-text">{project.desc}</p> styleMode="override"
className="w-100"
>
<div className="card-content py-2">
<p className="opacity-75" style={{ fontSize: '0.95rem' }}>
{project.desc}
</p>
</div> </div>
</div> </Card>
</motion.div> </a>
))} </motion.div>
</div> ))}
</div>
</section>
);
};
const InterestsSection = () => {
const interests = ['Informática', 'Electrónica', 'Videojuegos', 'Música', 'Animanga', 'Ciberseguridad'];
return (
<section>
<h2 className="section-title">INTERESES</h2>
<div className="row g-3">
{interests.map((interest, i) => (
<motion.div
key={i}
className="col-md-4 col-6"
whileHover={{ scale: 1.02 }}
>
<div className="interest-item rounded shadow-sm h-100 d-flex align-items-center">
<span>{interest}</span>
</div>
</motion.div>
))}
</div> </div>
</section> </section>
); );

View File

@@ -1,49 +1,77 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Card from "@/components/Card";
import { motion } from "framer-motion";
import '@/css/Projects.css'
export default function ProjectsLanding() { export default function ProjectsLanding() {
const [repos, setRepos] = useState([]); const [repos, setRepos] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const fetchRepos = async () => { const fetchRepos = async () => {
try { const GITEA_URL = "https://git.miarma.net";
const userResponse = await fetch("https://api.github.com/users/Gallardo7761/repos"); const USERNAME = "Gallardo7761";
const userRepos = await userResponse.json();
setRepos(userRepos); try {
const response = await fetch(`${GITEA_URL}/api/v1/users/${USERNAME}/repos?limit=100`);
if (!response.ok) throw new Error("Fallo en la conexión");
const data = await response.json();
setRepos(data);
} catch (error) { } catch (error) {
console.error("Error al obtener repos:", error); console.error("Error:", error);
} finally {
setLoading(false);
} }
}; };
fetchRepos(); fetchRepos();
}, []); }, []);
return ( return (
<div className="container mt-5 mb-5 text-white"> <div className="projects-container">
<h1 className="text-center text-dark mb-4">Mis Proyectos en GitHub</h1> <main className="container py-5">
<p className="text-center text-muted mb-4"> {loading ? (
(Copia temporal del mirror de GitHub, próximamente desde mi propia instancia Gitea!!!) <div className="text-center py-5">
</p> <div className="spinner-border primary" role="status"></div>
<div className="row"> <p className="mt-2">Cargando repos...</p>
{repos.map((repo) => (
<div key={repo.id} className="col-md-6 col-lg-4 mb-4">
<a
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-decoration-none"
>
<div className="card bg-dark text-white shadow-sm h-100">
<div className="card-body">
<h5 className="card-title">{repo.name}</h5>
<p className="card-text text-light">
{repo.description || "Sin descripción"}
</p>
</div>
</div>
</a>
</div> </div>
))} ) : (
</div> <div className="row g-4">
{repos.map((repo, index) => (
<motion.div
key={repo.id}
className="col-md-6 col-lg-4 d-flex"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.05 }}
>
<a
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-decoration-none w-100"
>
<Card
title={repo.name}
status={repo.language || "Desconocido"}
styleMode="override"
className="h-100 w-100"
>
<div className="card-content">
<p className="opacity-75" style={{ fontSize: '0.9em' }}>
{repo.description || "Este repo es tan secreto que no tiene ni descripción."}
</p>
</div>
<div className="mt-3 d-flex justify-content-between align-items-center">
<small className="opacity-50"> {repo.stars_count || 0}</small>
{/*<small className="opacity-50">🍴 {repo.forks_count || 0}</small>*/}
</div>
</Card>
</a>
</motion.div>
))}
</div>
)}
</main>
</div> </div>
); );
} }