Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0927b9d665 | ||
|
|
3bb0a5e4a9 |
@@ -6,3 +6,5 @@ README.md
|
||||
.env*
|
||||
.next
|
||||
.git
|
||||
.github
|
||||
k8s
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
name: CI and Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
env:
|
||||
REGISTRY: tea.jesseinit.dev
|
||||
IMAGE_NAME: ${{ github.repository_owner }}/personal-site
|
||||
NAMESPACE: personal-site
|
||||
DEPLOYMENT: jesseinit-portfolio
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: Build site
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# The Gitea runner image only caches Node 24; match the Dockerfile's Node 22.
|
||||
container: node:22-bookworm
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Validate Kubernetes manifests
|
||||
run: |
|
||||
curl -fsSL "https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl" -o /usr/local/bin/kubectl
|
||||
chmod +x /usr/local/bin/kubectl
|
||||
kubectl kustomize k8s/ > /dev/null
|
||||
|
||||
build-image:
|
||||
name: Build and push image
|
||||
needs: ci
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=sha,format=long
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
cache-to: type=inline
|
||||
|
||||
deploy:
|
||||
name: Deploy to Kubernetes
|
||||
needs: build-image
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up kubectl
|
||||
uses: azure/setup-kubectl@v5.1.0
|
||||
with:
|
||||
version: v1.34.3
|
||||
|
||||
- name: Configure kubectl
|
||||
env:
|
||||
KUBE_CONFIG_B64: ${{ secrets.KUBE_CONFIG }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${KUBE_CONFIG_B64}" ]; then
|
||||
echo "::error::KUBE_CONFIG is not configured in the production environment or repository."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
KUBECONFIG_PATH="${RUNNER_TEMP}/personal-site-kubeconfig"
|
||||
umask 077
|
||||
printf '%s' "${KUBE_CONFIG_B64}" | base64 --decode > "${KUBECONFIG_PATH}"
|
||||
echo "KUBECONFIG=${KUBECONFIG_PATH}" >> "${GITHUB_ENV}"
|
||||
|
||||
KUBECONFIG="${KUBECONFIG_PATH}" kubectl cluster-info --request-timeout=15s
|
||||
|
||||
- name: Pin image to this commit
|
||||
run: sed -i "s/newTag: latest/newTag: sha-${GITHUB_SHA}/" k8s/kustomization.yaml
|
||||
|
||||
- name: Apply manifests
|
||||
run: kubectl apply -k k8s/
|
||||
|
||||
- name: Wait for rollout
|
||||
run: |
|
||||
if ! kubectl rollout status "deployment/${DEPLOYMENT}" -n "${NAMESPACE}" --timeout=5m; then
|
||||
kubectl get pods -n "${NAMESPACE}" -o wide
|
||||
kubectl describe pods -n "${NAMESPACE}" | tail -40
|
||||
kubectl rollout undo "deployment/${DEPLOYMENT}" -n "${NAMESPACE}" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
kubectl get deployments,pods,services,ingress -n "${NAMESPACE}"
|
||||
echo "### Deployed sha-${GITHUB_SHA} to ${NAMESPACE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
+8
-4
@@ -16,8 +16,12 @@ RUN npm run build
|
||||
# Run
|
||||
FROM base AS run
|
||||
ENV NODE_ENV=production
|
||||
RUN mkdir .next
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/.next/standalone ./
|
||||
COPY --from=build /app/.next/static ./.next/static
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
RUN mkdir .next && chown node:node .next
|
||||
COPY --from=build --chown=node:node /app/public ./public
|
||||
COPY --from=build --chown=node:node /app/.next/standalone ./
|
||||
COPY --from=build --chown=node:node /app/.next/static ./.next/static
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+46
-19
@@ -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>
|
||||
<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>
|
||||
{showNavLinks && (
|
||||
|
||||
<div className="nav-links">
|
||||
<Link href="/" className="nav-link">
|
||||
Home
|
||||
</Link>
|
||||
<Link href="/rates" className="nav-link cta">
|
||||
Rates
|
||||
{links.map((link) => (
|
||||
<Link key={link.href} href={link.href} className="nav-link" onClick={() => setOpen(false)}>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!showNavLinks && (
|
||||
<div className="nav-links">
|
||||
<a href="mailto:me@jesseinit.dev" className="nav-link cta">
|
||||
Get in touch
|
||||
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
// Single source of truth for site content. Edit here, the UI follows.
|
||||
|
||||
export const profile = {
|
||||
name: 'Jesse Egbosionu',
|
||||
firstName: 'Jesse',
|
||||
handles: ['Jesse Egbosionu', 'jesseinit', 'jessewithit'],
|
||||
role: 'Software Engineer',
|
||||
location: 'Amsterdam, NL',
|
||||
email: 'me@jesseinit.dev',
|
||||
available: true,
|
||||
tagline: 'I build backend systems that scale, and the products on top of them.',
|
||||
intro:
|
||||
'I architect and scale backend systems that handle millions of requests. From payment processing to logistics orchestration, I turn complex technical challenges into reliable production infrastructure.',
|
||||
about: [
|
||||
'I’m a software engineer based in Amsterdam with 6+ years of experience shipping products across finance, e-commerce, and logistics.',
|
||||
'Most of my work lives on the backend: designing APIs, modelling data, and running services in production. I care about systems that are boring in the best way: observable, well-tested, and easy for the next engineer to pick up.',
|
||||
'When I’m not at work, I build small tools that scratch my own itch.',
|
||||
],
|
||||
};
|
||||
|
||||
export const stats = [
|
||||
{ value: '6+', label: 'Years shipping' },
|
||||
{ value: '3', label: 'Industries' },
|
||||
{ value: 'M+', label: 'Requests served' },
|
||||
];
|
||||
|
||||
export const focusAreas = [
|
||||
{
|
||||
title: 'Backend architecture',
|
||||
description: 'Service design, APIs and data models that hold up under real traffic.',
|
||||
icon: 'server',
|
||||
},
|
||||
{
|
||||
title: 'Payments & fintech',
|
||||
description: 'Transaction flows, reconciliation and integrations with payment providers.',
|
||||
icon: 'card',
|
||||
},
|
||||
{
|
||||
title: 'Logistics orchestration',
|
||||
description: 'Event-driven workflows that move orders, parcels and people on time.',
|
||||
icon: 'route',
|
||||
},
|
||||
{
|
||||
title: 'Infra & delivery',
|
||||
description: 'Containerised deployments on Kubernetes, CI/CD and production observability.',
|
||||
icon: 'cloud',
|
||||
},
|
||||
];
|
||||
|
||||
export const stack = [
|
||||
{ group: 'Languages', items: ['Python', 'JavaScript', 'Go'] },
|
||||
{ group: 'Frameworks', items: ['Django', 'FastAPI', 'Flask', 'Node.js', 'React', 'Next.js'] },
|
||||
{ group: 'Data', items: ['PostgreSQL', 'MongoDB', 'Redis', 'Kafka', 'Pub/Sub'] },
|
||||
{ group: 'Infrastructure', items: ['Docker', 'Kubernetes', 'CI/CD'] },
|
||||
];
|
||||
|
||||
export const projects = [
|
||||
{
|
||||
title: 'LibreOffice Conversion API',
|
||||
description: 'A stateless REST API that exposes document conversion through the LibreOffice CLI.',
|
||||
tags: ['Python', 'Flask', 'Docker'],
|
||||
repo: 'https://github.com/jesseinit/flask_libreoffice_api',
|
||||
},
|
||||
{
|
||||
title: 'Store Manager',
|
||||
description: 'A web app that helps store owners manage sales and product inventory records.',
|
||||
tags: ['React', 'Node.js', 'PostgreSQL'],
|
||||
repo: 'https://github.com/jesseinit/store-manager-react',
|
||||
},
|
||||
{
|
||||
title: 'Feather Insure',
|
||||
description: 'A small insurance recommendation API.',
|
||||
tags: ['Python', 'API'],
|
||||
repo: 'https://github.com/jesseinit/feather-insure',
|
||||
},
|
||||
{
|
||||
title: 'RSS Reader API',
|
||||
description: 'A REST API that lets users read and follow their favourite RSS feeds.',
|
||||
tags: ['Python', 'REST'],
|
||||
repo: 'https://github.com/jesseinit/rss_reader_api',
|
||||
},
|
||||
];
|
||||
|
||||
export const socials = [
|
||||
{ name: 'GitHub', url: 'https://github.com/jesseinit', icon: 'github' },
|
||||
{ name: 'LinkedIn', url: 'https://linkedin.com/in/jesse-egbosionu/', icon: 'linkedin' },
|
||||
{ name: 'X', url: 'https://twitter.com/jesseinit/', icon: 'x' },
|
||||
];
|
||||
+31
-214
@@ -1,225 +1,42 @@
|
||||
# Kubernetes Deployment Guide
|
||||
# Kubernetes deployment
|
||||
|
||||
This directory contains all the necessary Kubernetes manifests to deploy the jesseinit portfolio application.
|
||||
The site runs on the personal k3s cluster in the `personal-site` namespace and is served at
|
||||
https://jesseinit.dev (`www.` redirects to the apex). TLS comes from cert-manager (`letsencrypt-prod`).
|
||||
|
||||
## Prerequisites
|
||||
## How deploys work
|
||||
|
||||
- Kubernetes cluster (local or cloud)
|
||||
- kubectl CLI installed and configured
|
||||
- Docker for building the image
|
||||
- (Optional) kustomize for managing configurations
|
||||
`.github/workflows/deploy.yml` (run by Gitea Actions) on every push to `master`:
|
||||
|
||||
## Files Overview
|
||||
1. **Build site**: `npm ci && npm run build`, and renders the kustomization to catch manifest errors.
|
||||
2. **Build and push image**: `tea.jesseinit.dev/jesseinit/personal-site:sha-<commit>` and `:latest`.
|
||||
3. **Deploy**: pins `newTag` in `kustomization.yaml` to `sha-<commit>`, runs `kubectl apply -k k8s/`,
|
||||
waits for the rollout, and rolls back automatically if it fails.
|
||||
|
||||
- `deployment.yaml` - Kubernetes Deployment with 2 replicas, health checks, and resource limits
|
||||
- `service.yaml` - ClusterIP Service exposing the application on port 80
|
||||
- `ingress.yaml` - Ingress resource for external access (requires ingress controller)
|
||||
- `configmap.yaml` - ConfigMap for environment variables
|
||||
- `kustomization.yaml` - Kustomize configuration for easy deployment management
|
||||
Pull requests run step 1 only. Old images are cleaned up by the nightly `gitea-package-prune` cronjob.
|
||||
|
||||
## Quick Start
|
||||
### Required secrets (repo or `production` environment in Gitea)
|
||||
|
||||
### 1. Build the Docker Image
|
||||
| Secret | Value |
|
||||
| ---------------- | ----------------------------------------------------- |
|
||||
| `REGISTRY_TOKEN` | Gitea token with `write:package` for `jesseinit` |
|
||||
| `KUBE_CONFIG` | base64-encoded kubeconfig for the personal cluster |
|
||||
|
||||
## Files
|
||||
|
||||
- `namespace.yaml`: `personal-site` namespace
|
||||
- `deployment.yaml`: 2 replicas, zero-downtime rolling updates, non-root, health checks
|
||||
- `service.yaml`: ClusterIP on port 80 → container port 3000
|
||||
- `ingress.yaml`: nginx ingress + TLS for `jesseinit.dev` / `www.jesseinit.dev`
|
||||
- `configmap.yaml`: optional environment variables
|
||||
- `kustomization.yaml`: ties it together and sets the image
|
||||
|
||||
## Manual operations
|
||||
|
||||
```bash
|
||||
# Build the image
|
||||
docker build -t jesseinit-portfolio:latest .
|
||||
kubectl --context personal-cluster -n personal-site get pods,ingress,certificate
|
||||
kubectl --context personal-cluster -n personal-site logs -l app=jesseinit-portfolio -f
|
||||
kubectl --context personal-cluster -n personal-site rollout undo deployment/jesseinit-portfolio
|
||||
|
||||
# If using a registry, tag and push
|
||||
docker tag jesseinit-portfolio:latest <your-registry>/jesseinit-portfolio:latest
|
||||
docker push <your-registry>/jesseinit-portfolio:latest
|
||||
# Deploy by hand (uses :latest)
|
||||
kubectl --context personal-cluster apply -k k8s/
|
||||
```
|
||||
|
||||
### 2. Update Image Reference (if using a registry)
|
||||
|
||||
Edit `k8s/kustomization.yaml` and update the image reference:
|
||||
|
||||
```yaml
|
||||
images:
|
||||
- name: jesseinit-portfolio
|
||||
newName: <your-registry>/jesseinit-portfolio
|
||||
newTag: latest
|
||||
```
|
||||
|
||||
### 3. Deploy to Kubernetes
|
||||
|
||||
#### Option A: Using kubectl
|
||||
|
||||
```bash
|
||||
# Apply all manifests
|
||||
kubectl apply -f k8s/
|
||||
|
||||
# Verify deployment
|
||||
kubectl get pods -l app=jesseinit-portfolio
|
||||
kubectl get svc jesseinit-portfolio
|
||||
kubectl get ingress jesseinit-portfolio
|
||||
```
|
||||
|
||||
#### Option B: Using Kustomize
|
||||
|
||||
```bash
|
||||
# Deploy using kustomize
|
||||
kubectl apply -k k8s/
|
||||
|
||||
# Or build and preview first
|
||||
kubectl kustomize k8s/ | kubectl apply -f -
|
||||
```
|
||||
|
||||
### 4. Configure Ingress (Optional)
|
||||
|
||||
Edit `k8s/ingress.yaml` to match your setup:
|
||||
|
||||
1. Update the `host` field with your domain
|
||||
2. Set the correct `ingressClassName` (nginx, traefik, etc.)
|
||||
3. Uncomment TLS section if using HTTPS
|
||||
4. Add appropriate annotations for your ingress controller
|
||||
|
||||
Example for NGINX with cert-manager:
|
||||
|
||||
```yaml
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
```
|
||||
|
||||
### 5. Add Environment Variables (Optional)
|
||||
|
||||
Edit `k8s/configmap.yaml` to add any required environment variables:
|
||||
|
||||
```yaml
|
||||
data:
|
||||
API_URL: "https://api.example.com"
|
||||
NEXT_PUBLIC_SITE_URL: "https://jesseinit.com"
|
||||
```
|
||||
|
||||
## Local Development with Minikube
|
||||
|
||||
```bash
|
||||
# Start minikube
|
||||
minikube start
|
||||
|
||||
# Build image in minikube's Docker daemon
|
||||
eval $(minikube docker-env)
|
||||
docker build -t jesseinit-portfolio:latest .
|
||||
|
||||
# Deploy
|
||||
kubectl apply -k k8s/
|
||||
|
||||
# Access the application
|
||||
minikube service jesseinit-portfolio
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Check Pod Status
|
||||
|
||||
```bash
|
||||
kubectl get pods -l app=jesseinit-portfolio
|
||||
kubectl logs -l app=jesseinit-portfolio -f
|
||||
kubectl describe pod <pod-name>
|
||||
```
|
||||
|
||||
### Check Service
|
||||
|
||||
```bash
|
||||
kubectl get svc jesseinit-portfolio
|
||||
kubectl describe svc jesseinit-portfolio
|
||||
```
|
||||
|
||||
### Port Forward for Local Testing
|
||||
|
||||
```bash
|
||||
kubectl port-forward svc/jesseinit-portfolio 8080:80
|
||||
# Access at http://localhost:8080
|
||||
```
|
||||
|
||||
## Scaling
|
||||
|
||||
```bash
|
||||
# Scale to 3 replicas
|
||||
kubectl scale deployment jesseinit-portfolio --replicas=3
|
||||
|
||||
# Or edit deployment.yaml and reapply
|
||||
```
|
||||
|
||||
## Updating the Application
|
||||
|
||||
```bash
|
||||
# Build new image with new tag
|
||||
docker build -t jesseinit-portfolio:v2 .
|
||||
docker push <your-registry>/jesseinit-portfolio:v2
|
||||
|
||||
# Update the image
|
||||
kubectl set image deployment/jesseinit-portfolio portfolio=<your-registry>/jesseinit-portfolio:v2
|
||||
|
||||
# Or update kustomization.yaml and reapply
|
||||
kubectl apply -k k8s/
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pods not starting
|
||||
|
||||
```bash
|
||||
kubectl describe pod <pod-name>
|
||||
kubectl logs <pod-name>
|
||||
```
|
||||
|
||||
### Service not accessible
|
||||
|
||||
```bash
|
||||
kubectl get endpoints jesseinit-portfolio
|
||||
kubectl describe svc jesseinit-portfolio
|
||||
```
|
||||
|
||||
### Image pull errors
|
||||
|
||||
If using a private registry, create an image pull secret:
|
||||
|
||||
```bash
|
||||
kubectl create secret docker-registry regcred \
|
||||
--docker-server=<your-registry> \
|
||||
--docker-username=<username> \
|
||||
--docker-password=<password> \
|
||||
--docker-email=<email>
|
||||
```
|
||||
|
||||
Then add to `deployment.yaml`:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: regcred
|
||||
```
|
||||
|
||||
## Resource Management
|
||||
|
||||
The deployment includes default resource requests and limits:
|
||||
|
||||
- Requests: 128Mi memory, 100m CPU
|
||||
- Limits: 512Mi memory, 500m CPU
|
||||
|
||||
Adjust these in `deployment.yaml` based on your application's needs.
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
# Delete all resources
|
||||
kubectl delete -k k8s/
|
||||
|
||||
# Or delete individually
|
||||
kubectl delete -f k8s/
|
||||
```
|
||||
|
||||
## Production Considerations
|
||||
|
||||
1. **Use a specific image tag** instead of `latest` for better version control
|
||||
2. **Set up HorizontalPodAutoscaler** for automatic scaling
|
||||
3. **Configure persistent storage** if needed (for logs, cache, etc.)
|
||||
4. **Add network policies** for security
|
||||
5. **Set up monitoring** with Prometheus/Grafana
|
||||
6. **Configure TLS/SSL** certificates for HTTPS
|
||||
7. **Use secrets** for sensitive data instead of ConfigMaps
|
||||
8. **Implement resource quotas** and limit ranges
|
||||
9. **Set up backup and disaster recovery** procedures
|
||||
10. **Use namespace isolation** for multi-environment deployments
|
||||
|
||||
@@ -6,6 +6,12 @@ metadata:
|
||||
app: jesseinit-portfolio
|
||||
spec:
|
||||
replicas: 2
|
||||
revisionHistoryLimit: 5
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 0
|
||||
maxSurge: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: jesseinit-portfolio
|
||||
@@ -24,12 +30,20 @@ spec:
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
- name: HOSTNAME
|
||||
value: "0.0.0.0"
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
- name: NEXT_TELEMETRY_DISABLED
|
||||
value: "1"
|
||||
envFrom:
|
||||
- configMapRef:
|
||||
name: jesseinit-portfolio-config
|
||||
optional: true
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1000
|
||||
allowPrivilegeEscalation: false
|
||||
resources:
|
||||
requests:
|
||||
memory: "128Mi"
|
||||
|
||||
+11
-10
@@ -5,13 +5,19 @@ metadata:
|
||||
labels:
|
||||
app: jesseinit-portfolio
|
||||
annotations:
|
||||
# Uncomment and configure based on your ingress controller
|
||||
# nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
# cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
# Redirect www.jesseinit.dev -> jesseinit.dev
|
||||
nginx.ingress.kubernetes.io/from-to-www-redirect: "true"
|
||||
spec:
|
||||
ingressClassName: nginx # Change to your ingress class (nginx, traefik, etc.)
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- jesseinit.dev
|
||||
- www.jesseinit.dev
|
||||
secretName: personal-site-tls
|
||||
rules:
|
||||
- host: jesseinit.com # Replace with your domain
|
||||
- host: jesseinit.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
@@ -21,8 +27,3 @@ spec:
|
||||
name: jesseinit-portfolio
|
||||
port:
|
||||
number: 80
|
||||
# Uncomment for TLS/HTTPS support
|
||||
# tls:
|
||||
# - hosts:
|
||||
# - jesseinit.com
|
||||
# secretName: jesseinit-portfolio-tls
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
namespace: default
|
||||
namespace: personal-site
|
||||
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- configmap.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
|
||||
commonLabels:
|
||||
app: jesseinit-portfolio
|
||||
labels:
|
||||
- pairs:
|
||||
app.kubernetes.io/name: personal-site
|
||||
app.kubernetes.io/managed-by: kustomize
|
||||
|
||||
# CI rewrites newTag to sha-<commit> before applying.
|
||||
images:
|
||||
- name: jesseinit-portfolio
|
||||
newName: jesseinit-portfolio
|
||||
newName: tea.jesseinit.dev/jesseinit/personal-site
|
||||
newTag: latest
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: personal-site
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document';
|
||||
|
||||
// Set the theme before first paint to avoid a light/dark flash.
|
||||
const themeScript = `(function(){try{var t=localStorage.getItem('theme');if(!t){t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.dataset.theme=t}catch(e){}})();`;
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<link rel="preload" href="/fonts/inter_font.ttf" as="font" type="font/ttf" crossOrigin="anonymous" />
|
||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -240,7 +240,11 @@ export default async (req, res) => {
|
||||
.catch((error) => reject(error));
|
||||
})
|
||||
.then((result) => {
|
||||
const ngnRate = result.rates.EUR.NGN;
|
||||
// Afriex now rejects unauthenticated requests with {"message":"Device id missing"}
|
||||
const ngnRate = result?.rates?.EUR?.NGN;
|
||||
if (!ngnRate) {
|
||||
throw new Error(`Unexpected response: ${result?.message || JSON.stringify(result).slice(0, 200)}`);
|
||||
}
|
||||
return {
|
||||
rate: parseFloat(ngnRate).toFixed(2),
|
||||
provider: "Afriex",
|
||||
@@ -250,7 +254,7 @@ export default async (req, res) => {
|
||||
};
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error calling Afriex:", error);
|
||||
console.error("Error calling Afriex:", error.message);
|
||||
return createDefaultErrorResponse("Afriex", "https://www.afriex.com/");
|
||||
});
|
||||
|
||||
|
||||
+160
-33
@@ -1,53 +1,180 @@
|
||||
import Layout from '../components/Layout';
|
||||
import SocialLinks from '../components/SocialLinks';
|
||||
import AnimatedName from '../components/AnimatedName';
|
||||
import SectionHeading from '../components/SectionHeading';
|
||||
import ProjectCard from '../components/ProjectCard';
|
||||
import Reveal from '../components/Reveal';
|
||||
import Icon from '../components/Icon';
|
||||
import { profile, stats, focusAreas, stack, projects } from '../data/profile';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Layout showNavLinks={false}>
|
||||
<Layout>
|
||||
{/* HERO */}
|
||||
<section className="hero">
|
||||
<h1 className="hero-title">
|
||||
Hey, I'm <AnimatedName />
|
||||
</h1>
|
||||
<div className="hero-main">
|
||||
<div className="hero-backdrop" aria-hidden="true" />
|
||||
<div className="shell hero-grid">
|
||||
<div className="hero-content">
|
||||
|
||||
<p className="hero-subtitle">
|
||||
A software engineer building systems that scale in Amsterdam
|
||||
{profile.available && (
|
||||
<p className="status-pill">
|
||||
<span className="live-dot" />
|
||||
Open to new opportunities
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="hero-description">
|
||||
<p>
|
||||
I architect and scale backend systems that handle millions of requests.
|
||||
From payment processing to logistics orchestration, I turn complex
|
||||
technical challenges into reliable production infrastructure.
|
||||
</p>
|
||||
<h1 className="hero-title">
|
||||
Hey, I’m {profile.firstName}.
|
||||
<span className="hero-title-muted">{profile.tagline}</span>
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
6 years shipping products across <strong>finance</strong>, <strong>ecommerce</strong>, and <strong>logistics</strong>.
|
||||
I work with Python (Django, FastAPI), JavaScript (React, Node.js), PostgreSQL,
|
||||
Docker, Redis, and Kubernetes to build systems that just work.
|
||||
</p>
|
||||
</div>
|
||||
<p className="hero-subtitle">{profile.intro}</p>
|
||||
|
||||
<SocialLinks />
|
||||
|
||||
<a href="mailto:me@jesseinit.dev" className="primary-cta">
|
||||
<span>Let's work together</span>
|
||||
<span className="arrow">→</span>
|
||||
<div className="hero-actions">
|
||||
<a href={`mailto:${profile.email}`} className="button button-primary">
|
||||
Let’s work together
|
||||
<Icon name="arrowRight" size={16} />
|
||||
</a>
|
||||
<a href="#projects" className="button button-ghost">
|
||||
View projects
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="hero-image-wrapper">
|
||||
<div className="image-backdrop"></div>
|
||||
<img
|
||||
src="hero.jpg"
|
||||
alt="Jesse Egbosionu"
|
||||
className="hero-image"
|
||||
/>
|
||||
<div className="hero-meta">
|
||||
<span className="meta-item">
|
||||
<Icon name="pin" size={16} /> {profile.location}
|
||||
</span>
|
||||
<span className="meta-divider" />
|
||||
<SocialLinks variant="icons" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-visual">
|
||||
<div className="portrait">
|
||||
<img src="/hero.jpg" alt={profile.name} className="portrait-image" />
|
||||
</div>
|
||||
<div className="terminal-card" aria-hidden="true">
|
||||
<div className="terminal-bar">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
<em>~/whoami</em>
|
||||
</div>
|
||||
<pre className="terminal-body">
|
||||
<code>
|
||||
<span className="t-prompt">$</span> whoami{'\n'}
|
||||
<span className="t-accent">
|
||||
<AnimatedName />
|
||||
</span>
|
||||
{'\n'}
|
||||
<span className="t-prompt">$</span> cat role.txt{'\n'}
|
||||
<span className="t-muted">{profile.role.toLowerCase()} @ {profile.location.split(',')[0].toLowerCase()}</span>
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shell">
|
||||
<dl className="stats">
|
||||
{stats.map((stat, i) => (
|
||||
<Reveal key={stat.label} className="stat" delay={i * 80}>
|
||||
<dt className="stat-label">{stat.label}</dt>
|
||||
<dd className="stat-value">{stat.value}</dd>
|
||||
</Reveal>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ABOUT */}
|
||||
<section id="about" className="section">
|
||||
<div className="shell">
|
||||
<SectionHeading index="01" eyebrow="About" title="Engineering for the long run." />
|
||||
<div className="about-grid">
|
||||
<Reveal className="about-copy">
|
||||
{profile.about.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
</Reveal>
|
||||
<div className="focus-grid">
|
||||
{focusAreas.map((area, i) => (
|
||||
<Reveal key={area.title} className="focus-card" delay={i * 80}>
|
||||
<span className="focus-icon">
|
||||
<Icon name={area.icon} size={20} />
|
||||
</span>
|
||||
<h3 className="focus-title">{area.title}</h3>
|
||||
<p className="focus-description">{area.description}</p>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* STACK */}
|
||||
<section id="stack" className="section">
|
||||
<div className="shell">
|
||||
<SectionHeading index="02" eyebrow="Stack" title="Tools I reach for.">
|
||||
The technologies I use day to day to design, build and run production systems.
|
||||
</SectionHeading>
|
||||
<div className="stack-grid">
|
||||
{stack.map((group, i) => (
|
||||
<Reveal key={group.group} className="stack-group" delay={i * 80}>
|
||||
<h3 className="stack-title">{group.group}</h3>
|
||||
<ul className="tag-list">
|
||||
{group.items.map((item) => (
|
||||
<li key={item} className="tag tag-large">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* PROJECTS */}
|
||||
<section id="projects" className="section">
|
||||
<div className="shell">
|
||||
<SectionHeading index="03" eyebrow="Projects" title="Things I’ve built.">
|
||||
A mix of production tools and side projects. More on{' '}
|
||||
<a href="https://github.com/jesseinit" target="_blank" rel="noopener noreferrer" className="text-link">
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</SectionHeading>
|
||||
<div className="project-grid">
|
||||
{projects.map((project, i) => (
|
||||
<Reveal key={project.title} delay={(i % 3) * 80} className={project.featured ? 'span-2' : ''}>
|
||||
<ProjectCard project={project} />
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CONTACT */}
|
||||
<section id="contact" className="section">
|
||||
<div className="shell">
|
||||
<Reveal className="contact-card">
|
||||
<p className="eyebrow">
|
||||
<span className="eyebrow-index">04</span>
|
||||
Contact
|
||||
</p>
|
||||
<h2 className="contact-title">Have a system that needs to scale?</h2>
|
||||
<p className="contact-lead">
|
||||
I’m always happy to talk about backend architecture, interesting problems or new roles. My inbox is open.
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<a href={`mailto:${profile.email}`} className="button button-primary">
|
||||
<Icon name="mail" size={16} />
|
||||
{profile.email}
|
||||
</a>
|
||||
<SocialLinks variant="pills" />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
+29
-29
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Layout from "../components/Layout";
|
||||
import RateCard from "../components/RateCard";
|
||||
import Icon from "../components/Icon";
|
||||
import { fetchData } from "../utils/api";
|
||||
|
||||
export default function Rates() {
|
||||
@@ -49,25 +50,27 @@ export default function Rates() {
|
||||
|
||||
return (
|
||||
<Layout
|
||||
title="EUR to NGN Exchange Rates - Jesse Egbosionu"
|
||||
title="EUR → NGN Exchange Rates · Jesse Egbosionu"
|
||||
description="Compare live EUR to NGN exchange rates across multiple providers."
|
||||
showNavLinks={false}
|
||||
>
|
||||
<section className="rates-section">
|
||||
<section className="section rates-section">
|
||||
<div className="shell">
|
||||
<div className="rates-header">
|
||||
<div className="rates-title-group">
|
||||
<h1 className="rates-title">Exchange Rates</h1>
|
||||
<p className="rates-subtitle">
|
||||
Compare live EUR to NGN rates across providers
|
||||
<div className="section-heading">
|
||||
<p className="eyebrow">
|
||||
<span className="live-dot" />
|
||||
Live tool
|
||||
</p>
|
||||
<h1 className="section-title">Exchange rates</h1>
|
||||
<p className="section-lead">
|
||||
Compare live EUR → NGN rates across remittance providers, fetched in real time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="currency-display">
|
||||
<div className="currency-badge">
|
||||
<span className="currency-from">€1 EUR</span>
|
||||
<span className="currency-arrow">→</span>
|
||||
<span className="currency-to">NGN</span>
|
||||
</div>
|
||||
<span>€1 EUR</span>
|
||||
<Icon name="arrowRight" size={16} />
|
||||
<span>NGN</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,22 +78,21 @@ export default function Rates() {
|
||||
<div className="rates-grid">
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<div key={index} className="rate-card skeleton">
|
||||
<div className="skeleton-header"></div>
|
||||
<div className="skeleton-rate"></div>
|
||||
<div className="skeleton-badge"></div>
|
||||
<div className="skeleton-line skeleton-header"></div>
|
||||
<div className="skeleton-line skeleton-rate"></div>
|
||||
<div className="skeleton-line skeleton-badge"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-state">
|
||||
<div className="error-icon">⚠️</div>
|
||||
<h3 className="error-title">Unable to load rates</h3>
|
||||
<p className="error-message">{error}</p>
|
||||
<div className="state-card">
|
||||
<h3 className="state-title">Unable to load rates</h3>
|
||||
<p className="state-message">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="retry-button"
|
||||
className="button button-primary"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
@@ -115,11 +117,9 @@ export default function Rates() {
|
||||
</div>
|
||||
|
||||
<div className="rates-info">
|
||||
<p className="info-text">
|
||||
Rates are updated regularly. Click on a provider to visit their website.
|
||||
</p>
|
||||
<p className="info-timestamp">
|
||||
Last updated: {new Date().toLocaleString('en-GB', {
|
||||
<p>Rates are fetched live on each visit. Click a provider to visit their website.</p>
|
||||
<p className="rates-timestamp">
|
||||
Last updated {new Date().toLocaleString('en-GB', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
})}
|
||||
@@ -129,14 +129,14 @@ export default function Rates() {
|
||||
)}
|
||||
|
||||
{!loading && !error && (!data || data.length === 0) && (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📊</div>
|
||||
<h3 className="empty-title">No rates available</h3>
|
||||
<p className="empty-message">
|
||||
<div className="state-card">
|
||||
<h3 className="state-title">No rates available</h3>
|
||||
<p className="state-message">
|
||||
We couldn't find any exchange rates at the moment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
+1205
-669
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user