Initial website was built base on the context
This commit is contained in:
@@ -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 Alex’s 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, I’m <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}>
|
||||
Let’s 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 you’re building, where it feels stuck, and what a good outcome looks like. I’ll take it from there."
|
||||
aside={<div className="contact-pulse"><span /> Usually replies within 1–2 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>What’s 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>I’m 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user