feat: redesign site as a modern developer portfolio
- New home page: hero, stats, about, focus areas, stack, projects, contact - Light/dark theme with system default and persisted toggle - Content centralised in data/profile.js - Restyle /rates to match; remove it from nav and projects - Afriex: log a clear error now that its API requires a device id Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5c40a64c42
commit
3bb0a5e4a9
@@ -1,27 +1,33 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { profile } from '../data/profile';
|
||||
|
||||
const names = ['👨🏿💼 Jesse Egbosionu', '👨🏿💻 jesseinit', '😎 jessewithit'];
|
||||
const names = profile.handles;
|
||||
|
||||
export default function AnimatedName() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
|
||||
let timeout;
|
||||
const interval = setInterval(() => {
|
||||
setIsAnimating(true);
|
||||
|
||||
setTimeout(() => {
|
||||
timeout = setTimeout(() => {
|
||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % names.length);
|
||||
setIsAnimating(false);
|
||||
}, 500); // Half of animation duration for smooth transition
|
||||
}, 3000); // Change name every 3 seconds
|
||||
}, 350);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<span className="animated-name-wrapper">
|
||||
<span className={`highlight animated-name ${isAnimating ? 'fade-out' : 'fade-in'}`}>
|
||||
<span className="animated-name" aria-label={names[0]}>
|
||||
<span aria-hidden="true" className={isAnimating ? 'is-leaving' : 'is-entering'}>
|
||||
{names[currentIndex]}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import SocialLinks from './SocialLinks';
|
||||
import { profile } from '../data/profile';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="footer-content">
|
||||
<p>
|
||||
Built with <span className="heart">❤️</span>
|
||||
<div className="shell footer-content">
|
||||
<p className="footer-text">
|
||||
© {new Date().getFullYear()} {profile.name}. Built with Next.js, shipped on Kubernetes.
|
||||
</p>
|
||||
<p className="footer-year">© {new Date().getFullYear()}</p>
|
||||
<SocialLinks variant="icons" />
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
+51
-24
@@ -1,31 +1,58 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import Icon from './Icon';
|
||||
|
||||
const links = [
|
||||
{ href: '/#about', label: 'About' },
|
||||
{ href: '/#stack', label: 'Stack' },
|
||||
{ href: '/#projects', label: 'Projects' },
|
||||
];
|
||||
|
||||
export default function Header() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
export default function Header({ showNavLinks = true }) {
|
||||
return (
|
||||
<header className="header">
|
||||
<nav className="navbar">
|
||||
<div className="logo">
|
||||
<Link href="/">
|
||||
<span className="logo-text">JE</span>
|
||||
</Link>
|
||||
<header className={`header ${scrolled ? 'is-scrolled' : ''} ${open ? 'is-open' : ''}`}>
|
||||
<nav className="navbar shell">
|
||||
<Link href="/" className="logo" onClick={() => setOpen(false)}>
|
||||
<span className="logo-mark">je</span>
|
||||
<span className="logo-text">
|
||||
jesseinit<span className="logo-dot">.dev</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="nav-links">
|
||||
{links.map((link) => (
|
||||
<Link key={link.href} href={link.href} className="nav-link" onClick={() => setOpen(false)}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="nav-actions">
|
||||
<ThemeToggle />
|
||||
<a href="/#contact" className="button button-primary button-small nav-cta">
|
||||
Contact
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button nav-menu"
|
||||
aria-label={open ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Icon name={open ? 'close' : 'menu'} />
|
||||
</button>
|
||||
</div>
|
||||
{showNavLinks && (
|
||||
<div className="nav-links">
|
||||
<Link href="/" className="nav-link">
|
||||
Home
|
||||
</Link>
|
||||
<Link href="/rates" className="nav-link cta">
|
||||
Rates
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!showNavLinks && (
|
||||
<div className="nav-links">
|
||||
<a href="mailto:me@jesseinit.dev" className="nav-link cta">
|
||||
Get in touch
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
const paths = {
|
||||
github: (
|
||||
<path
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
),
|
||||
linkedin: (
|
||||
<path
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"
|
||||
/>
|
||||
),
|
||||
x: (
|
||||
<path
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
),
|
||||
arrowRight: <path d="M5 12h14M13 6l6 6-6 6" />,
|
||||
arrowUpRight: <path d="M7 17 17 7M8 7h9v9" />,
|
||||
mail: (
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</>
|
||||
),
|
||||
sun: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</>
|
||||
),
|
||||
moon: <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />,
|
||||
server: (
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="7" rx="2" />
|
||||
<rect x="3" y="13" width="18" height="7" rx="2" />
|
||||
<path d="M7 7.5h.01M7 16.5h.01" />
|
||||
</>
|
||||
),
|
||||
card: (
|
||||
<>
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
<path d="M2 10h20M6 15h4" />
|
||||
</>
|
||||
),
|
||||
route: (
|
||||
<>
|
||||
<circle cx="6" cy="19" r="2" />
|
||||
<circle cx="18" cy="5" r="2" />
|
||||
<path d="M8 19h8.5a3.5 3.5 0 0 0 0-7h-9a3.5 3.5 0 0 1 0-7H16" />
|
||||
</>
|
||||
),
|
||||
cloud: <path d="M17.5 19a4.5 4.5 0 1 0-1.41-8.78A6 6 0 0 0 4.5 12.5 3.5 3.5 0 0 0 6 19z" />,
|
||||
pin: (
|
||||
<>
|
||||
<path d="M12 21s-7-6.2-7-11a7 7 0 1 1 14 0c0 4.8-7 11-7 11z" />
|
||||
<circle cx="12" cy="10" r="2.5" />
|
||||
</>
|
||||
),
|
||||
menu: <path d="M4 7h16M4 12h16M4 17h16" />,
|
||||
close: <path d="M6 6l12 12M18 6 6 18" />,
|
||||
};
|
||||
|
||||
export default function Icon({ name, size = 18, className = '' }) {
|
||||
return (
|
||||
<svg
|
||||
className={`icon ${className}`}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{paths[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+13
-8
@@ -2,19 +2,24 @@ import Head from 'next/head';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
|
||||
export default function Layout({ children, title, description, showNavLinks = true }) {
|
||||
export default function Layout({ children, title, description }) {
|
||||
const pageTitle = title || 'Jesse Egbosionu · Software Engineer';
|
||||
const pageDescription =
|
||||
description || 'Software engineer based in Amsterdam, focused on building scalable backend systems.';
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="page">
|
||||
<Head>
|
||||
<title>{title || 'Jesse Egbosionu - Software Engineer'}</title>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={pageDescription} />
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={pageDescription} />
|
||||
<meta property="og:type" content="website" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta
|
||||
name="description"
|
||||
content={description || 'Software engineer based in Amsterdam, focused on building scalable backend systems.'}
|
||||
/>
|
||||
</Head>
|
||||
|
||||
<Header showNavLinks={showNavLinks} />
|
||||
<Header />
|
||||
|
||||
<main className="main-content">{children}</main>
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import Link from 'next/link';
|
||||
import Icon from './Icon';
|
||||
|
||||
export default function ProjectCard({ project }) {
|
||||
const { title, description, tags, href, repo, featured, internal } = project;
|
||||
const primaryHref = href || repo;
|
||||
|
||||
return (
|
||||
<article className={`project-card ${featured ? 'is-featured' : ''}`}>
|
||||
<div className="project-top">
|
||||
{featured ? (
|
||||
<span className="pill pill-accent">
|
||||
<span className="live-dot" /> Live
|
||||
</span>
|
||||
) : (
|
||||
<Icon name="github" size={20} className="project-glyph" />
|
||||
)}
|
||||
{repo && href && (
|
||||
<a href={repo} target="_blank" rel="noopener noreferrer" className="project-repo" aria-label={`${title} source code`}>
|
||||
<Icon name="github" size={16} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="project-title">
|
||||
{internal ? (
|
||||
<Link href={primaryHref} className="project-link">
|
||||
{title}
|
||||
</Link>
|
||||
) : (
|
||||
<a href={primaryHref} target="_blank" rel="noopener noreferrer" className="project-link">
|
||||
{title}
|
||||
</a>
|
||||
)}
|
||||
<Icon name="arrowUpRight" size={18} className="project-arrow" />
|
||||
</h3>
|
||||
<p className="project-description">{description}</p>
|
||||
|
||||
<ul className="tag-list">
|
||||
{tags.map((tag) => (
|
||||
<li key={tag} className="tag">
|
||||
{tag}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
+8
-11
@@ -1,30 +1,27 @@
|
||||
import Icon from './Icon';
|
||||
|
||||
export default function RateCard({ provider, rate, href, isBest, index, formatCurrency, logo }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`rate-card ${isBest ? 'best' : ''}`}
|
||||
style={{ animationDelay: `${index * 0.1}s` }}
|
||||
className={`rate-card ${isBest ? 'is-best' : ''}`}
|
||||
style={{ animationDelay: `${index * 60}ms` }}
|
||||
>
|
||||
<div className="rate-card-header">
|
||||
<div className="provider-info">
|
||||
{logo && (
|
||||
<img
|
||||
src={logo}
|
||||
alt={`${provider} logo`}
|
||||
className="provider-logo"
|
||||
/>
|
||||
)}
|
||||
{logo && <img src={logo} alt="" className="provider-logo" />}
|
||||
<h3 className="provider-name">{provider}</h3>
|
||||
</div>
|
||||
{isBest && <span className="best-badge">Best Rate</span>}
|
||||
{isBest && <span className="pill pill-accent">Best rate</span>}
|
||||
</div>
|
||||
|
||||
<div className="rate-value">{formatCurrency(rate)}</div>
|
||||
|
||||
<div className="rate-card-footer">
|
||||
<span className="visit-link">Visit provider →</span>
|
||||
<span>Visit provider</span>
|
||||
<Icon name="arrowUpRight" size={16} className="rate-arrow" />
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
// Fades children in when they scroll into view.
|
||||
export default function Reveal({ as: Tag = 'div', delay = 0, className = '', children, ...rest }) {
|
||||
const ref = useRef(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
if (!node || typeof IntersectionObserver === 'undefined') {
|
||||
setVisible(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -10% 0px' }
|
||||
);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Tag
|
||||
ref={ref}
|
||||
className={`reveal ${visible ? 'is-visible' : ''} ${className}`}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Reveal from './Reveal';
|
||||
|
||||
export default function SectionHeading({ index, eyebrow, title, children }) {
|
||||
return (
|
||||
<Reveal className="section-heading">
|
||||
<p className="eyebrow">
|
||||
<span className="eyebrow-index">{index}</span>
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h2 className="section-title">{title}</h2>
|
||||
{children && <p className="section-lead">{children}</p>}
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,20 @@
|
||||
const socialData = [
|
||||
{
|
||||
name: 'GitHub',
|
||||
url: 'https://github.com/jesseinit',
|
||||
icon: (
|
||||
<svg className="social-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'LinkedIn',
|
||||
url: 'https://linkedin.com/in/jesse-egbosionu/',
|
||||
icon: (
|
||||
<svg className="social-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Twitter',
|
||||
url: 'https://twitter.com/jesseinit/',
|
||||
icon: (
|
||||
<svg className="social-icon" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
import Icon from './Icon';
|
||||
import { socials } from '../data/profile';
|
||||
|
||||
export default function SocialLinks() {
|
||||
export default function SocialLinks({ variant = 'icons' }) {
|
||||
return (
|
||||
<div className="social-links">
|
||||
{socialData.map((social) => (
|
||||
<div className={`social-links social-links-${variant}`}>
|
||||
{socials.map((social) => (
|
||||
<a
|
||||
key={social.name}
|
||||
href={social.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="social-link"
|
||||
className={variant === 'icons' ? 'icon-button' : 'social-link'}
|
||||
aria-label={social.name}
|
||||
>
|
||||
{social.icon}
|
||||
{social.name}
|
||||
<Icon name={social.icon} size={variant === 'icons' ? 16 : 18} />
|
||||
{variant !== 'icons' && <span>{social.name}</span>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Icon from './Icon';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(document.documentElement.dataset.theme || 'light');
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
const next = theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
try {
|
||||
localStorage.setItem('theme', next);
|
||||
} catch (e) {}
|
||||
setTheme(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={toggle}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
|
||||
>
|
||||
<Icon name={theme === 'dark' ? 'sun' : 'moon'} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user