Initial website was built base on the context

This commit is contained in:
StormRunner06106
2026-09-11 10:13:41 -07:00
parent 1e532f4c42
commit f80bceaa3c
31 changed files with 4832 additions and 1 deletions
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import { Route, Routes, useLocation } from "react-router-dom";
import { getProfile } from "./api";
import Layout from "./components/Layout";
import AboutPage from "./pages/AboutPage";
import BlogPage from "./pages/BlogPage";
import BlogPostPage from "./pages/BlogPostPage";
import ContactPage from "./pages/ContactPage";
import ExperiencePage from "./pages/ExperiencePage";
import NotFoundPage from "./pages/NotFoundPage";
import SkillsPage from "./pages/SkillsPage";
function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo({ top: 0, behavior: "instant" });
}, [pathname]);
return null;
}
export default function App() {
const [profile, setProfile] = useState(null);
const [profileError, setProfileError] = useState("");
useEffect(() => {
const controller = new AbortController();
getProfile(controller.signal)
.then(setProfile)
.catch((error) => {
if (error.name !== "AbortError") setProfileError(error.message);
});
return () => controller.abort();
}, []);
return (
<Layout profile={profile}>
<ScrollToTop />
<Routes>
<Route
path="/"
element={<AboutPage error={profileError} profile={profile} />}
/>
<Route path="/experience" element={<ExperiencePage />} />
<Route path="/skills" element={<SkillsPage />} />
<Route path="/blog" element={<BlogPage />} />
<Route path="/blog/:slug" element={<BlogPostPage />} />
<Route
path="/contact"
element={<ContactPage error={profileError} profile={profile} />}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Layout>
);
}
+36
View File
@@ -0,0 +1,36 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
async function request(path, options = {}) {
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
if (!response.ok) {
let detail = "Something went wrong. Please try again.";
try {
const body = await response.json();
detail = body.detail ?? detail;
} catch {
// Keep the friendly fallback when a proxy or server returns non-JSON.
}
throw new Error(detail);
}
return response.json();
}
export const getProfile = (signal) => request("/api/profile", { signal });
export const getExperience = (signal) => request("/api/experience", { signal });
export const getSkills = (signal) => request("/api/skills", { signal });
export const getPosts = (signal) => request("/api/posts", { signal });
export const getPost = (slug, signal) => request(`/api/posts/${slug}`, { signal });
export const sendContactMessage = (payload) =>
request("/api/contact", {
method: "POST",
body: JSON.stringify(payload),
});
+54
View File
@@ -0,0 +1,54 @@
const paths = {
arrow: <path d="M5 12h14m-5-5 5 5-5 5" />,
arrowLeft: <path d="m11 17-5-5 5-5m-5 5h13" />,
arrowUpRight: <path d="M7 17 17 7M8 7h9v9" />,
calendar: (
<>
<rect x="3" y="5" width="18" height="16" rx="3" />
<path d="M16 3v4M8 3v4M3 10h18" />
</>
),
check: <path d="m5 12 4 4L19 6" />,
chevron: <path d="m8 10 4 4 4-4" />,
close: <path d="m6 6 12 12M18 6 6 18" />,
code: <path d="m8 9-3 3 3 3m8-6 3 3-3 3m-2-9-4 12" />,
email: (
<>
<rect x="3" y="5" width="18" height="14" rx="3" />
<path d="m4 7 8 6 8-6" />
</>
),
github: (
<path d="M15 22v-3.9c.04-1-.35-1.75-.9-2.25 3-.34 6.15-1.48 6.15-6.68A5.2 5.2 0 0 0 18.86 5.5a4.85 4.85 0 0 0-.13-3.63S17.64 1.52 15 3.27a12.5 12.5 0 0 0-6 0C6.36 1.52 5.27 1.87 5.27 1.87a4.85 4.85 0 0 0-.13 3.63 5.2 5.2 0 0 0-1.39 3.67c0 5.19 3.15 6.34 6.14 6.68-.44.4-.77.98-.86 1.7-.78.35-2.74.95-3.95-1.13-.25-.4-.86-1.38-1.76-1.36-.96.02-.39.55.01.77.65.36 1.1 1.74 1.24 2.17.22.63.94 1.83 4.42 1.32V22" />
),
linkedin: (
<>
<path d="M7 9v10M7 5.5v.01M11 19v-5.5a4 4 0 0 1 8 0V19M11 9v10" />
<rect x="3" y="3" width="18" height="18" rx="4" />
</>
),
menu: <path d="M4 7h16M4 12h16M4 17h16" />,
search: <path d="m20 20-4.35-4.35m2.35-5.15a7.5 7.5 0 1 1-15 0 7.5 7.5 0 0 1 15 0Z" />,
send: <path d="m21 3-7.5 18-3.8-7.7L2 9.5 21 3Zm-11.3 10.3L14 9" />,
spark: <path d="M12 2c.6 5.4 3.6 8.4 9 9-5.4.6-8.4 3.6-9 9-.6-5.4-3.6-8.4-9-9 5.4-.6 8.4-3.6 9-9Z" />,
};
export default function Icon({ name, size = 20, className = "" }) {
return (
<svg
aria-hidden="true"
className={className}
fill="none"
height={size}
viewBox="0 0 24 24"
width={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
>
{paths[name]}
</svg>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import { NavLink, useLocation } from "react-router-dom";
import Icon from "./Icon";
const navItems = [
{ label: "About", to: "/" },
{ label: "Experience", to: "/experience" },
{ label: "Skills", to: "/skills" },
{ label: "Journal", to: "/blog" },
{ label: "Contact", to: "/contact" },
];
function Header({ name }) {
const [menuOpen, setMenuOpen] = useState(false);
const location = useLocation();
useEffect(() => setMenuOpen(false), [location.pathname]);
return (
<header className="site-header">
<div className="container header-inner">
<NavLink className="brand" to="/" aria-label={`${name} home`}>
<span className="brand-mark">AH</span>
<span className="brand-name">{name}</span>
</NavLink>
<nav className={`nav-shell ${menuOpen ? "is-open" : ""}`} aria-label="Main navigation">
{navItems.map((item) => (
<NavLink
className={({ isActive }) => `nav-link ${isActive ? "is-active" : ""}`}
end={item.to === "/"}
key={item.to}
to={item.to}
>
{item.label}
</NavLink>
))}
</nav>
<button
aria-expanded={menuOpen}
aria-label={menuOpen ? "Close navigation" : "Open navigation"}
className="menu-button"
onClick={() => setMenuOpen((open) => !open)}
type="button"
>
<Icon name={menuOpen ? "close" : "menu"} />
</button>
</div>
</header>
);
}
function Footer({ name }) {
const now = new Date();
const monthAndYear = new Intl.DateTimeFormat("en-US", {
month: "short",
year: "numeric",
}).format(now);
return (
<footer className="site-footer">
<div className="container footer-inner">
<p>© {now.getFullYear()} · All rights reserved · {monthAndYear}</p>
<p className="signature" aria-label={`${name} signature`}>
{name}
</p>
</div>
</footer>
);
}
export default function Layout({ children, profile }) {
const location = useLocation();
const name = profile?.display_name ?? "Alex Herlan";
const isAbout = location.pathname === "/";
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">Skip to content</a>
<Header name={name} />
<main className={`site-main ${isAbout ? "site-main--about" : ""}`} id="main-content">
{children}
</main>
<Footer name={name} />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
export default function PageHeader({ eyebrow, title, description, aside }) {
return (
<header className="page-heading reveal">
<div>
<p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
{description && <p className="page-description">{description}</p>}
</div>
{aside && <div className="page-heading__aside">{aside}</div>}
</header>
);
}
+21
View File
@@ -0,0 +1,21 @@
export function LoadingState({ label = "Loading" }) {
return (
<div className="state-card" role="status">
<span className="loader" aria-hidden="true" />
<p>{label}</p>
</div>
);
}
export function ErrorState({ message }) {
return (
<div className="state-card state-card--error" role="alert">
<span className="state-icon">!</span>
<div>
<strong>That didnt load.</strong>
<p>{message}</p>
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+70
View File
@@ -0,0 +1,70 @@
import Icon from "../components/Icon";
import { ErrorState, LoadingState } from "../components/Status";
export default function AboutPage({ profile, error }) {
if (error) {
return (
<div className="container centered-state">
<ErrorState message={error} />
</div>
);
}
if (!profile) {
return (
<div className="container centered-state">
<LoadingState label="Loading Alexs profile" />
</div>
);
}
return (
<section className="about-page container">
<div className="about-copy reveal">
<p className="eyebrow">
<span className="availability-dot" />
{profile.eyebrow}
</p>
<h1>
Hello, Im <span>{profile.display_name}.</span>
</h1>
<h2>{profile.headline}</h2>
<p className="about-summary">{profile.summary}</p>
<div className="hero-actions">
<a className="button button--primary" href={profile.resume_url} target="_blank" rel="noreferrer">
Open CV
<Icon name="arrowUpRight" size={18} />
</a>
<a className="button button--quiet" href={profile.socials.find((social) => social.kind === "email")?.href}>
Lets talk
<Icon name="arrow" size={18} />
</a>
</div>
<div className="focus-row" aria-label="Current focus areas">
{profile.focus.map((item) => (
<span key={item}>{item}</span>
))}
</div>
</div>
<div className="portrait-stage reveal reveal--delay">
<div className="portrait-orbit portrait-orbit--one" />
<div className="portrait-orbit portrait-orbit--two" />
<div className="portrait-frame">
<img src={profile.portrait} alt={`${profile.name}, senior software engineer`} />
</div>
<div className="experience-note">
<strong>27+</strong>
<span>years building<br />with technology</span>
</div>
<div className="availability-note">
<Icon name="spark" size={18} />
<span>{profile.availability}</span>
</div>
</div>
</section>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { getPosts } from "../api";
import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader";
import { ErrorState, LoadingState } from "../components/Status";
function formatDate(date) {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(`${date}T12:00:00`));
}
function PostCard({ post, featured }) {
return (
<article className={`post-card post-card--${post.accent} ${featured ? "post-card--featured" : ""} reveal`}>
<Link to={`/blog/${post.slug}`} aria-label={`Read ${post.title}`}>
<div className="post-card__art" aria-hidden="true">
<span className="art-ring" />
<span className="art-code">{post.tags[0]}</span>
<Icon name="spark" size={featured ? 34 : 26} />
</div>
<div className="post-card__body">
<div className="post-meta">
<span>{formatDate(post.published_at)}</span>
<span>{post.read_time} min read</span>
</div>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<div className="post-card__footer">
<div className="mini-tags">
{post.tags.slice(0, 2).map((tag) => <span key={tag}>{tag}</span>)}
</div>
<span className="read-arrow"><Icon name="arrowUpRight" size={18} /></span>
</div>
</div>
</Link>
</article>
);
}
export default function BlogPage() {
const [posts, setPosts] = useState([]);
const [query, setQuery] = useState("");
const [activeTag, setActiveTag] = useState("All");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
getPosts(controller.signal)
.then(setPosts)
.catch((requestError) => {
if (requestError.name !== "AbortError") setError(requestError.message);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
const tags = useMemo(() => {
const counts = new Map();
posts.flatMap((post) => post.tags).forEach((tag) => counts.set(tag, (counts.get(tag) ?? 0) + 1));
return ["All", ...[...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6).map(([tag]) => tag)];
}, [posts]);
const filteredPosts = useMemo(() => {
const needle = query.trim().toLowerCase();
return posts.filter((post) => {
const matchesTag = activeTag === "All" || post.tags.includes(activeTag);
const matchesQuery = !needle || `${post.title} ${post.excerpt} ${post.tags.join(" ")}`.toLowerCase().includes(needle);
return matchesTag && matchesQuery;
});
}, [activeTag, posts, query]);
return (
<section className="content-page container">
<PageHeader
eyebrow="Journal"
title="Notes from the build."
description="Practical observations on applied AI, resilient products, and the technology choices behind them."
aside={<p className="issue-count">{posts.length || 10}<span>field notes</span></p>}
/>
<div className="journal-tools reveal">
<label className="search-box">
<span className="sr-only">Search articles</span>
<Icon name="search" size={18} />
<input
onChange={(event) => setQuery(event.target.value)}
placeholder="Search the journal"
type="search"
value={query}
/>
</label>
<div className="filter-row" aria-label="Filter articles by topic">
{tags.map((tag) => (
<button
className={activeTag === tag ? "is-active" : ""}
key={tag}
onClick={() => setActiveTag(tag)}
type="button"
>
{tag}
</button>
))}
</div>
</div>
{loading && <LoadingState label="Fetching field notes" />}
{error && <ErrorState message={error} />}
{!loading && !error && filteredPosts.length > 0 && (
<div className="posts-grid">
{filteredPosts.map((post, index) => (
<PostCard featured={index === 0 && !query && activeTag === "All"} key={post.slug} post={post} />
))}
</div>
)}
{!loading && !error && filteredPosts.length === 0 && (
<div className="empty-card">
<Icon name="search" />
<h2>No notes found</h2>
<p>Try a different phrase or topic.</p>
</div>
)}
</section>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { getPost } from "../api";
import Icon from "../components/Icon";
import { ErrorState, LoadingState } from "../components/Status";
function formatDate(date) {
return new Intl.DateTimeFormat("en-US", {
month: "long",
day: "numeric",
year: "numeric",
}).format(new Date(`${date}T12:00:00`));
}
export default function BlogPostPage() {
const { slug } = useParams();
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
setLoading(true);
getPost(slug, controller.signal)
.then(setPost)
.catch((requestError) => {
if (requestError.name !== "AbortError") setError(requestError.message);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [slug]);
if (loading) {
return <div className="container content-page"><LoadingState label="Opening the note" /></div>;
}
if (error) {
return <div className="container content-page"><ErrorState message={error} /></div>;
}
return (
<article className="article-page">
<header className={`article-hero article-hero--${post.accent}`}>
<div className="article-hero__shape" aria-hidden="true"><Icon name="spark" size={50} /></div>
<div className="article-container reveal">
<Link className="back-link" to="/blog"><Icon name="arrowLeft" size={17} /> Back to journal</Link>
<div className="article-tags">
{post.tags.map((tag) => <span key={tag}>{tag}</span>)}
</div>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
<div className="article-byline">
<span className="mini-avatar">AH</span>
<span><strong>Alex Herlan</strong><small>{formatDate(post.published_at)} · {post.read_time} min read</small></span>
</div>
</div>
</header>
<div className="article-container article-content">
{post.content.map((section) => (
<section key={section.heading}>
<h2>{section.heading}</h2>
{section.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</section>
))}
<div className="article-end">
<Icon name="spark" />
<p>Thanks for reading.</p>
<Link to="/contact">Continue the conversation <Icon name="arrow" size={17} /></Link>
</div>
</div>
</article>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { useState } from "react";
import { sendContactMessage } from "../api";
import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader";
const initialForm = {
name: "",
email: "",
subject: "",
message: "",
company: "",
};
export default function ContactPage({ profile, error }) {
const [form, setForm] = useState(initialForm);
const [status, setStatus] = useState({ type: "idle", message: "" });
function updateField(event) {
setForm((current) => ({ ...current, [event.target.name]: event.target.value }));
}
async function submitForm(event) {
event.preventDefault();
setStatus({ type: "sending", message: "Sending your note…" });
try {
const result = await sendContactMessage(form);
setStatus({ type: "success", message: result.message });
setForm(initialForm);
} catch (requestError) {
setStatus({ type: "error", message: requestError.message });
}
}
const socials = profile?.socials ?? [];
return (
<section className="content-page container contact-page">
<PageHeader
eyebrow="Contact"
title="Have a useful problem to solve?"
description="Tell me what youre building, where it feels stuck, and what a good outcome looks like. Ill take it from there."
aside={<div className="contact-pulse"><span /> Usually replies within 12 days</div>}
/>
<div className="contact-grid">
<form className="contact-form reveal" onSubmit={submitForm}>
<div className="form-row">
<label>
<span>Your name</span>
<input
autoComplete="name"
minLength="2"
name="name"
onChange={updateField}
placeholder="Jane Smith"
required
value={form.name}
/>
</label>
<label>
<span>Email address</span>
<input
autoComplete="email"
name="email"
onChange={updateField}
placeholder="jane@company.com"
required
type="email"
value={form.email}
/>
</label>
</div>
<label>
<span>Whats this about?</span>
<input
minLength="3"
name="subject"
onChange={updateField}
placeholder="A product, role, or interesting collaboration"
required
value={form.subject}
/>
</label>
<label>
<span>Your message</span>
<textarea
maxLength="5000"
minLength="20"
name="message"
onChange={updateField}
placeholder="A little context goes a long way…"
required
rows="7"
value={form.message}
/>
</label>
<label className="honeypot" aria-hidden="true">
Company
<input name="company" onChange={updateField} tabIndex="-1" value={form.company} />
</label>
<div className="form-footer">
<button className="button button--primary" disabled={status.type === "sending"} type="submit">
{status.type === "sending" ? "Sending…" : "Send message"}
<Icon name="send" size={18} />
</button>
{status.type !== "idle" && (
<p className={`form-status form-status--${status.type}`} role="status">{status.message}</p>
)}
</div>
</form>
<aside className="contact-aside reveal reveal--delay">
<div className="contact-note">
<Icon name="spark" size={24} />
<h2>Good conversations start with clarity.</h2>
<p>Im especially interested in full-stack product work, applied AI, and systems that make demanding workflows feel calmer.</p>
</div>
<div className="social-list">
<p className="eyebrow">Find me here</p>
{socials.map((social) => (
<a href={social.href} key={social.kind} target={social.kind === "email" ? undefined : "_blank"} rel="noreferrer">
<span className="social-icon"><Icon name={social.kind} /></span>
<span>
<small>{social.label}</small>
<strong>{social.value}</strong>
</span>
<Icon name="arrowUpRight" size={18} />
</a>
))}
{error && <p className="social-error">Contact links are temporarily unavailable.</p>}
</div>
</aside>
</div>
</section>
);
}
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useMemo, useState } from "react";
import { getExperience } from "../api";
import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader";
import { ErrorState, LoadingState } from "../components/Status";
function asDate(value) {
const [year, month] = value.split("-").map(Number);
return new Date(year, month - 1, 1);
}
function monthLabel(value) {
return new Intl.DateTimeFormat("en-US", {
month: "short",
year: "numeric",
}).format(asDate(value));
}
function durationLabel(start, end) {
const startDate = asDate(start);
const endDate = end ? asDate(end) : new Date();
const months = Math.max(
1,
(endDate.getFullYear() - startDate.getFullYear()) * 12 +
endDate.getMonth() -
startDate.getMonth(),
);
const years = Math.floor(months / 12);
const remainingMonths = months % 12;
const parts = [];
if (years) parts.push(`${years} yr${years === 1 ? "" : "s"}`);
if (remainingMonths) parts.push(`${remainingMonths} mo`);
return parts.join(" ") || "1 mo";
}
function TimelineItem({ item, index, isExpanded, onToggle }) {
const isCurrent = !item.end;
const year = item.start.slice(0, 4);
return (
<article className={`timeline-item reveal ${isCurrent ? "is-current" : ""}`}>
<div className="timeline-year" aria-hidden="true">
<span>{year}</span>
</div>
<div className="timeline-marker">
<span />
</div>
<div className="timeline-card">
<button
className="timeline-card__header"
onClick={onToggle}
type="button"
aria-expanded={isExpanded}
aria-controls={`experience-${item.id}`}
>
<div>
<div className="timeline-meta">
<span>{monthLabel(item.start)} {item.end ? monthLabel(item.end) : "Present"}</span>
<span>{durationLabel(item.start, item.end)}</span>
{isCurrent && <span className="current-label">Current</span>}
</div>
<h2>{item.role}</h2>
<p className="company-line">{item.company} <span>·</span> {item.location}</p>
</div>
<span className={`expand-button ${isExpanded ? "is-open" : ""}`}>
<Icon name="chevron" />
</span>
</button>
<div
className={`timeline-details ${isExpanded ? "is-open" : ""}`}
id={`experience-${item.id}`}
>
<div className="timeline-details__inner">
<p className="timeline-summary">{item.summary}</p>
<ul className="highlight-list">
{item.highlights.map((highlight) => (
<li key={highlight}>
<span><Icon name="check" size={14} /></span>
{highlight}
</li>
))}
</ul>
{item.projects && (
<div className="project-links">
{item.projects.map((project) => (
<a href={project.href} key={project.name} target="_blank" rel="noreferrer">
<span>
<strong>{project.name}</strong>
<small>{project.period}</small>
</span>
<Icon name="arrowUpRight" size={17} />
</a>
))}
</div>
)}
<div className="tag-row">
{item.technologies.map((technology) => (
<span key={technology}>{technology}</span>
))}
</div>
</div>
</div>
</div>
</article>
);
}
export default function ExperiencePage() {
const [experience, setExperience] = useState([]);
const [expandedId, setExpandedId] = useState("independent");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
getExperience(controller.signal)
.then(setExperience)
.catch((requestError) => {
if (requestError.name !== "AbortError") setError(requestError.message);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
const visibleYears = useMemo(() => {
if (!experience.length) return "2013 — now";
return `${experience.at(-1).start.slice(0, 4)} — now`;
}, [experience]);
return (
<section className="content-page container">
<PageHeader
eyebrow="Experience"
title="A career built close to the work."
description="Product engineering, critical operations, and technical leadership—connected by a habit of making complicated systems easier to use."
aside={
<div className="heading-stat">
<strong>{experience.length || "6"}</strong>
<span>career chapters<br />{visibleYears}</span>
</div>
}
/>
{loading && <LoadingState label="Mapping the timeline" />}
{error && <ErrorState message={error} />}
{!loading && !error && (
<div className="timeline">
{experience.map((item, index) => (
<TimelineItem
index={index}
isExpanded={expandedId === item.id}
item={item}
key={item.id}
onToggle={() => setExpandedId((active) => (active === item.id ? "" : item.id))}
/>
))}
</div>
)}
</section>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { Link } from "react-router-dom";
import Icon from "../components/Icon";
export default function NotFoundPage() {
return (
<section className="not-found container">
<p className="eyebrow">404 · Off the map</p>
<h1>This page took a different path.</h1>
<p>The link may be old, or the page may have moved.</p>
<Link className="button button--primary" to="/">Return home <Icon name="arrow" size={18} /></Link>
</section>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { getSkills } from "../api";
import Icon from "../components/Icon";
import PageHeader from "../components/PageHeader";
import { ErrorState, LoadingState } from "../components/Status";
export default function SkillsPage() {
const [skills, setSkills] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
getSkills(controller.signal)
.then(setSkills)
.catch((requestError) => {
if (requestError.name !== "AbortError") setError(requestError.message);
})
.finally(() => setLoading(false));
return () => controller.abort();
}, []);
return (
<section className="content-page container">
<PageHeader
eyebrow="Skills"
title="A practical, evolving toolkit."
description={skills?.intro ?? "The tools I use to take products from a rough idea to reliable software."}
aside={
<div className="skill-orbit" aria-hidden="true">
<Icon name="code" size={26} />
</div>
}
/>
{loading && <LoadingState label="Unpacking the toolkit" />}
{error && <ErrorState message={error} />}
{skills && (
<>
<div className="principles-panel reveal">
<p className="eyebrow">How I work</p>
<div className="principles-list">
{skills.principles.map((principle, index) => (
<div key={principle}>
<span>0{index + 1}</span>
<p>{principle}</p>
</div>
))}
</div>
</div>
<div className="skills-grid">
{skills.categories.map((category, index) => (
<article
className={`skill-card skill-card--${category.accent} reveal`}
key={category.name}
style={{ "--delay": `${index * 55}ms` }}
>
<div className="skill-card__top">
<span className="skill-index">0{index + 1}</span>
<span className="skill-dot" />
</div>
<h2>{category.name}</h2>
<p>{category.description}</p>
<div className="skill-tags">
{category.items.map((item) => (
<span key={item}>{item}</span>
))}
</div>
</article>
))}
</div>
</>
)}
</section>
);
}
File diff suppressed because it is too large Load Diff