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
+20
View File
@@ -0,0 +1,20 @@
# Frontend
frontend/node_modules/
frontend/dist/
# Python
__pycache__/
*.py[cod]
.venv/
venv/
# Local configuration
.env
backend/.env
# Editors and operating systems
.DS_Store
Thumbs.db
.idea/
.vscode/
+1 -1
View File
@@ -3,7 +3,7 @@
**Senior Software Engineer · DevOps & IT Manager**
1207 El Portal NW, Albuquerque, NM 87107
[813-402-8692](tel:+18134028692) · [alexwherlan@gmail.com](mailto:alexwherlan@gmail.com) · [alex.herlan.dev](https://alex.herlan.dev)
[813-402-8692](tel:+18134028692) · [alexwherlan@gmail.com](mailto:alexwherlan@gmail.com) · [alex.herlan.dev](https://alex.herlan.dev) · [LinkedIn](https://linkedin.com/in/alexander-herlan) · [GitHub](https://github.com/AlexanderHerlan)
## Overview
+77
View File
@@ -0,0 +1,77 @@
# Alex Herlan Portfolio
A custom React and FastAPI portfolio built from Alex Herlan's résumé. The interface uses hand-written CSS—no Tailwind or component library—and all profile, career, skills, and journal content is loaded from JSON through the API.
## Project structure
```text
backend/
data/ JSON content store
main.py FastAPI routes, SMTP delivery, and production serving
requirements.txt
frontend/
public/ Alex's locally stored profile image
src/ React pages, components, and custom CSS
Alexander Herlan Resume 2024.pdf
Alexander Herlan Resume 2024.md
```
## Run locally
Create and install the Python environment from the repository root:
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r backend\requirements.txt
```
Start FastAPI:
```powershell
uvicorn backend.main:app --reload --port 8000
```
In a second terminal, start React:
```powershell
cd frontend
npm install
npm run dev
```
Open `http://localhost:5173`. Vite proxies `/api` requests to FastAPI on port 8000.
## Contact-form delivery
The form uses `POST /api/contact` and sends email through SMTP. Copy `backend/.env.example` to `backend/.env`, fill in the SMTP values, and start the API with the environment file:
```powershell
uvicorn backend.main:app --reload --port 8000 --env-file backend\.env
```
If SMTP is unavailable, the API returns a clear delivery error and the page keeps Alex's direct email, LinkedIn, and GitHub links visible.
## Content API
- `GET /api/profile`
- `GET /api/experience`
- `GET /api/skills`
- `GET /api/posts`
- `GET /api/posts/{slug}`
- `GET /api/resume`
- `POST /api/contact`
Edit the files in `backend/data` to update portfolio content. No database is required.
## Production build
```powershell
cd frontend
npm run build
cd ..
uvicorn backend.main:app --host 0.0.0.0 --port 8000
```
When `frontend/dist` exists, FastAPI serves the built single-page application and supports direct links such as `/experience` and `/blog/{slug}`.
+15
View File
@@ -0,0 +1,15 @@
# Where contact-form messages should be delivered.
CONTACT_TO_EMAIL=alexwherlan@gmail.com
# SMTP connection used by POST /api/contact.
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password
SMTP_FROM_EMAIL=portfolio@example.com
SMTP_USE_TLS=true
SMTP_USE_SSL=false
# Comma-separated origins allowed to call the API during development.
FRONTEND_ORIGINS=http://localhost:5173
+2
View File
@@ -0,0 +1,2 @@
"""Alex Herlan portfolio API package."""
+116
View File
@@ -0,0 +1,116 @@
[
{
"id": "independent",
"company": "Independent",
"role": "Software Developer / Freelancer",
"location": "Remote",
"start": "2024-06",
"end": null,
"summary": "I develop web and mobile applications for long-term clients, agency partners, and professional referrals—owning the path from discovery and architecture through delivery, deployment, and support.",
"highlights": [
"Expanded an initial WordPress engagement into a complete membership and qualification platform for the Irish Institute of Pensions Management.",
"Built Android applications for an AI-assisted safety workflow that detects firearms and relays incident evidence to monitoring teams and first responders.",
"Delivered a mortgage-lending platform on Cloudflare with real-time messaging, relational data, and secure document storage."
],
"technologies": ["Preact", "Bun", "Cloudflare", "Android", "Kotlin", "TensorFlow Lite", "PHP", "PostgreSQL"],
"projects": [
{
"name": "IIPM.ie",
"period": "Apr 2025 Mar 2026",
"href": "https://iipm.ie"
},
{
"name": "SafeSchoolz",
"period": "Jun 2024 Jun 2025",
"href": "https://heroeyez.com"
},
{
"name": "Equity Funding Source",
"period": "Apr 2026 Jul 2026",
"href": "https://equityfundingsource.com"
}
]
},
{
"id": "covius",
"company": "Covius, LLC",
"role": "Senior Software Engineer",
"location": "Pompano Beach, FL",
"start": "2022-01",
"end": "2024-04",
"summary": "Helped transform RealtyBid.com into a modern, real-time real estate auction platform, spanning its REST backend, responsive Angular experience, legacy data, and internal administration tools.",
"highlights": [
"Participated in the complete redesign of a major legacy website with substantial historical data.",
"Helped adapt a legacy database to a modern application design.",
"Created a responsive interface that worked equally well on desktop and mobile.",
"Refined the product with QA testers and business partners over many collaborative sprints.",
"Used RxJS and Angular to deliver asynchronous, real-time auction feedback."
],
"technologies": ["Angular", "RxJS", "REST APIs", "JavaScript", "SQL"]
},
{
"id": "sead",
"company": "SEAD Software",
"role": "Software Engineer",
"location": "Delray Beach, FL",
"start": "2021-04",
"end": "2022-07",
"summary": "Designed web applications and business analytics for healthcare and legal teams while improving development operations through Azure DevOps and delivery pipelines.",
"highlights": [
"Used Google Cloud Vision to recognize and categorize physical laboratory documents.",
"Built a Twilio-based messaging application that responded to customer interactions and generated leads for law firms.",
"Maintained AWS infrastructure supporting multiple projects.",
"Implemented Azure DevOps infrastructure and trained staff on the platform.",
"Maintained a large Git repository serving multiple clients."
],
"technologies": ["Python", "Google Cloud Vision", "Twilio", "AWS", "Azure DevOps"]
},
{
"id": "clarity",
"company": "Clarity Diagnostics & Lab Solutions",
"role": "Director of IT",
"location": "Boca Raton, FL",
"start": "2020-09",
"end": "2022-01",
"summary": "Led technology decisions for a boutique medical testing laboratory and diagnostics manufacturer as it grew to serve outside entities, including local and state governments.",
"highlights": [
"Implemented physical keycard access security across the laboratory facility.",
"Established email and cloud-application security standards in Google Workspace and Microsoft 365.",
"Expanded laboratory infrastructure and assisted with quality assurance for medical testing equipment.",
"Managed software quality, deployment, training, and the daily work of other IT staff."
],
"technologies": ["IT Leadership", "Google Workspace", "Microsoft 365", "Security", "QA"]
},
{
"id": "mytap",
"company": "MyTap / Which Craft",
"role": "Lead Developer",
"location": "Albuquerque, NM",
"start": "2016-01",
"end": "2020-04",
"summary": "Collaborated with a design team on a craft-beer recommendation engine powered by web scraping and natural language processing of online reviews.",
"highlights": [
"Implemented a robust web scraper with Python and Scrapy.",
"Created a responsive browser-based phone experience.",
"Designed NLP code that built flavor profiles from adjective frequency in review text.",
"Maintained a Django backend backed by PostgreSQL."
],
"technologies": ["Python", "Scrapy", "NLP", "Django", "PostgreSQL"]
},
{
"id": "tricore",
"company": "TriCore Reference Laboratories",
"role": "Production Support Specialist",
"location": "Albuquerque, NM",
"start": "2013-04",
"end": "2017-01",
"summary": "Supported critical healthcare technology across regional hospitals, from urgent access issues to specialized clinical applications and patient-record transmission.",
"highlights": [
"Held sole overnight responsibility for a production environment normally supported by a 60-person daytime team.",
"Monitored patient-record transmission across multiple hospital systems and specialized clinical applications.",
"Guided medical staff through time-sensitive, patient-critical troubleshooting.",
"Monitored infrastructure, corrected errors, trained new help-desk employees, and remained on call for emergencies."
],
"technologies": ["Production Support", "Healthcare IT", "Monitoring", "Incident Response", "Training"]
}
]
+252
View File
@@ -0,0 +1,252 @@
[
{
"slug": "calm-systems-in-critical-environments",
"title": "Calm Systems in Critical Environments",
"excerpt": "What overnight healthcare support taught me about observability, escalation, and designing for the person under pressure.",
"published_at": "2013-10-16",
"read_time": 5,
"tags": ["Healthcare IT", "Operations", "Reliability"],
"accent": "mint",
"content": [
{
"heading": "The interface is part of the incident response",
"paragraphs": [
"In a critical environment, the best interface is rarely the one with the most information. It is the one that helps a tired person identify the next useful action. Clear severity, an obvious owner, and a short trail of recent events matter more than a decorative dashboard.",
"Production support made this tangible for me. When a patient-facing workflow is interrupted, uncertainty grows faster than the technical problem. Good systems reduce that uncertainty by making state visible and escalation paths explicit."
]
},
{
"heading": "Design for the handoff",
"paragraphs": [
"A fix is only half of an incident. The other half is leaving a useful record for the next person: what happened, what was checked, what changed, and what still needs attention. That small discipline compounds into organizational memory.",
"I now carry the same principle into software engineering. Logs should explain behavior, alerts should point toward decisions, and runbooks should be written for the moment when context is scarce. Reliability is a product experience for the people operating the product."
]
}
]
},
{
"slug": "turning-beer-reviews-into-flavor-maps",
"title": "Turning Beer Reviews into Flavor Maps",
"excerpt": "A practical early NLP experiment: translating messy, enthusiastic review text into a useful recommendation signal.",
"published_at": "2016-06-09",
"read_time": 6,
"tags": ["Python", "NLP", "Scrapy"],
"accent": "peach",
"content": [
{
"heading": "Start with the language people already use",
"paragraphs": [
"Beer reviews are expressive, inconsistent, and full of useful sensory language. That made them an interesting source for a recommendation engine. Instead of asking users to learn a rigid flavor taxonomy, we could learn from the adjectives reviewers naturally repeated.",
"The first challenge was collection. A Scrapy pipeline gathered public review text, normalized obvious variations, and retained enough source context to investigate outliers later. Clean provenance mattered because a mysterious score is difficult to improve."
]
},
{
"heading": "Simple models can create real value",
"paragraphs": [
"The flavor profiles began with frequency, weighting, and domain-specific synonym groups. It was not a giant language model; it was a focused tool with a clear job. That constraint made the output easier to explain and tune with the product team.",
"The lesson has aged well: use the smallest model that reliably improves the experience. Sophistication is valuable when it earns its place through better outcomes, not when it merely makes the architecture sound impressive."
]
}
]
},
{
"slug": "scrapy-pipelines-that-age-well",
"title": "Scrapy Pipelines That Age Well",
"excerpt": "Patterns for keeping a web-scraping system observable, respectful, and maintainable as source sites evolve.",
"published_at": "2018-03-22",
"read_time": 7,
"tags": ["Python", "Scrapy", "Data Engineering"],
"accent": "blue",
"content": [
{
"heading": "Assume the source will change",
"paragraphs": [
"A scraper is an agreement with a document you do not control. Selectors change, fields disappear, and a page that looked uniform reveals a decade of edge cases. The maintainable response is to isolate extraction rules, validate every record, and make failures visible before they quietly poison downstream data.",
"I prefer small spiders with explicit responsibilities and item pipelines that handle normalization. Fixtures from real pages make regression tests useful, while per-source metrics reveal whether a deployment changed yield or quality."
]
},
{
"heading": "Politeness is architecture",
"paragraphs": [
"Rate limits, caching, retries, and clear identification are not optional finishing touches. They shape the system. A respectful crawler is less likely to create operational trouble and usually produces more consistent data as a side effect.",
"The best scraping pipeline is boring to operate. It resumes safely, explains skipped records, and lets a developer update one source without fearing a cascade of unrelated changes."
]
}
]
},
{
"slug": "growing-it-without-growing-chaos",
"title": "Growing IT Without Growing Chaos",
"excerpt": "A field guide to scaling a small organizations technology through standards, documentation, and sensible defaults.",
"published_at": "2020-11-12",
"read_time": 5,
"tags": ["IT Leadership", "Security", "Operations"],
"accent": "yellow",
"content": [
{
"heading": "Consistency is a form of leverage",
"paragraphs": [
"Small organizations often grow through a series of urgent, reasonable exceptions. Soon every laptop, account, and workflow has its own history. The first step toward scale is not buying a larger platform—it is agreeing on a small number of defaults.",
"Identity, device setup, shared storage, physical access, and offboarding deserve documented paths. A consistent baseline makes support faster and security easier to reason about."
]
},
{
"heading": "Meet people where the work happens",
"paragraphs": [
"Policies fail when they ignore daily constraints. In a laboratory, uptime, traceability, and physical workflow are connected. Technology decisions have to account for gloves, shared stations, regulated data, and the cost of interrupting a test process.",
"Good IT leadership combines standards with observation. Spend time with the people doing the work, remove the friction that creates workarounds, and make the secure path the natural path."
]
}
]
},
{
"slug": "from-paper-to-structured-data-with-vision-ai",
"title": "From Paper to Structured Data with Vision AI",
"excerpt": "Lessons from using cloud computer vision to categorize laboratory documents without hiding uncertainty from operators.",
"published_at": "2021-07-08",
"read_time": 7,
"tags": ["AI", "Computer Vision", "Python"],
"accent": "lavender",
"content": [
{
"heading": "Recognition is only the first step",
"paragraphs": [
"An OCR or vision service can return impressive output while still leaving the product team with the difficult work: deciding which text matters, how confident the system should be, and when a person needs to review the result.",
"For laboratory documents, I treated extraction as a pipeline. Image quality checks came first, recognition second, domain validation third, and classification last. Keeping those stages separate made errors explainable."
]
},
{
"heading": "Build uncertainty into the workflow",
"paragraphs": [
"The safest automation does not pretend every prediction is equal. Confidence thresholds, structured validation, and a review queue allow a system to move quickly on clear cases while giving ambiguous documents the attention they deserve.",
"That human review is not a failure of AI. It is part of a complete product. Each correction can improve rules, training data, and monitoring, creating a feedback loop grounded in real operations."
]
}
]
},
{
"slug": "real-time-angular-without-the-tangle",
"title": "Real-Time Angular Without the Tangle",
"excerpt": "How to use RxJS as a clear model for changing state instead of letting streams spread complexity through an application.",
"published_at": "2022-05-19",
"read_time": 8,
"tags": ["Angular", "RxJS", "Frontend"],
"accent": "blue",
"content": [
{
"heading": "Name the events that matter",
"paragraphs": [
"A real-time auction interface has several clocks running at once: the servers state, the users intent, network latency, and the visible countdown. RxJS is useful because it gives those changes a vocabulary, but only if streams correspond to meaningful product events.",
"I start by naming the sources and defining which one is authoritative. Server events update the canonical auction state. Local actions can create an optimistic state, but they remain distinguishable until acknowledged."
]
},
{
"heading": "Keep the view boring",
"paragraphs": [
"The component should receive a coherent view model rather than coordinate half a dozen subscriptions. Combining streams at a boundary keeps templates predictable and makes loading, reconnecting, and error states testable.",
"Reactive code is clearest when it reads like a timeline. Explicit cancellation, narrow side effects, and descriptive operators help the next engineer understand not just what updates, but why."
]
}
]
},
{
"slug": "modern-apis-over-legacy-data",
"title": "Modern APIs Over Legacy Data",
"excerpt": "A careful approach to modernizing the experience around a mature database while protecting the history inside it.",
"published_at": "2023-09-14",
"read_time": 7,
"tags": ["REST APIs", "Databases", "Architecture"],
"accent": "mint",
"content": [
{
"heading": "History is part of the domain",
"paragraphs": [
"A legacy database is often described as a technical obstacle, but its irregularities usually encode years of business decisions. Replacing it without understanding that history can produce a cleaner schema and a less correct product.",
"A modern API can act as a translation boundary. It gives new clients consistent resources and validation while isolating the rules required to work safely with older records."
]
},
{
"heading": "Migrate in observable steps",
"paragraphs": [
"I favor small, measurable transitions: introduce the boundary, compare old and new behavior, backfill with reconciliation reports, and only then retire a legacy path. Every stage should have a way to answer whether data was lost or behavior changed.",
"Modernization succeeds when users experience steady improvement and operators retain confidence. The architecture matters, but trust is the real migration target."
]
}
]
},
{
"slug": "useful-object-detection-on-android",
"title": "Useful Object Detection on Android",
"excerpt": "Moving from a promising model to a practical TensorFlow Lite pipeline under mobile performance constraints.",
"published_at": "2024-10-03",
"read_time": 8,
"tags": ["AI", "Android", "TensorFlow Lite"],
"accent": "peach",
"content": [
{
"heading": "The device changes the definition of accurate",
"paragraphs": [
"A model can score well in a notebook and still be unusable on a phone. Frame rate, thermal limits, camera variability, and time-to-alert are part of accuracy because they determine whether the result arrives when it matters.",
"Testing YOLO and TensorFlow Lite options made the tradeoffs concrete. The right model was the one that produced stable detections within the devices compute budget, not simply the largest model we could convert."
]
},
{
"heading": "Train for the camera you have",
"paragraphs": [
"Public datasets supplied breadth, while custom captures exposed the angles, lighting, backgrounds, and object sizes the application would actually see. Reviewing false positives was especially valuable because a safety workflow has a real cost for every alert.",
"A production detection pipeline is model, camera, preprocessing, thresholds, evidence capture, and human response. Improving any one piece helps; measuring the entire path is what makes the feature useful."
]
}
]
},
{
"slug": "designing-an-alert-pipeline-people-can-trust",
"title": "Designing an Alert Pipeline People Can Trust",
"excerpt": "Why evidence, timing, and graceful failure matter as much as detection in an AI-assisted safety workflow.",
"published_at": "2025-06-26",
"read_time": 6,
"tags": ["System Design", "AI", "Twilio"],
"accent": "lavender",
"content": [
{
"heading": "An alert is a chain of decisions",
"paragraphs": [
"Detection starts the workflow; it does not complete it. A useful alert needs context, a timestamp, a clear source, supporting evidence, and a dependable path to a person who can evaluate it.",
"For a mobile safety platform, that meant pairing a screenshot with a short evidence clip, initiating a call workflow, and forwarding incident information to an existing monitoring portal. Each transition needed an explicit success and failure state."
]
},
{
"heading": "Design the degraded path first",
"paragraphs": [
"Networks disappear, uploads stall, and downstream services become unavailable. The product should preserve evidence locally, communicate delivery state honestly, and retry without multiplying alerts.",
"Trust grows when the interface distinguishes detected, queued, delivered, acknowledged, and resolved. Those words turn a complicated distributed workflow into a shared understanding for users and operators."
]
}
]
},
{
"slug": "small-durable-systems-on-cloudflare",
"title": "Small, Durable Systems on Cloudflare",
"excerpt": "A pragmatic architecture for lightweight products that still need relational data, files, and real-time coordination.",
"published_at": "2026-06-11",
"read_time": 7,
"tags": ["Cloudflare", "Preact", "Bun"],
"accent": "yellow",
"content": [
{
"heading": "Choose boundaries before services",
"paragraphs": [
"A lending workflow needs durable records, private documents, timely collaboration, and an audit-friendly trail. Those needs point to different storage and execution patterns, but they do not require a sprawling infrastructure diagram.",
"I used Workers for server-side execution, D1 for relational workflow data, R2 for documents, and Durable Objects for coordinated real-time conversations. Each service had one legible responsibility."
]
},
{
"heading": "Lightweight does not mean temporary",
"paragraphs": [
"Preact and Bun kept the application loop fast, while schema migrations, typed boundaries, and idempotent operations protected the data. The goal was not minimum code at any cost; it was the smallest system that could be operated with confidence.",
"That is the architecture I increasingly prefer: close to users, explicit about state, and modest enough that one engineer can still understand the full path of a request."
]
}
]
}
]
+33
View File
@@ -0,0 +1,33 @@
{
"name": "Alexander Herlan",
"display_name": "Alex Herlan",
"title": "Senior Software Engineer · DevOps & IT Manager",
"eyebrow": "Full-stack engineer · AI builder · systems thinker",
"headline": "I build dependable software for ideas that need to work in the real world.",
"summary": "Im a hands-on software engineer with more than 27 years across full-stack development, cloud infrastructure, IT leadership, and user experience. I turn complex requirements into calm, useful products—from responsive web platforms and legacy-system modernization to mobile AI and real-time operational tools.",
"availability": "Open to thoughtful product and engineering work",
"portrait": "/alexander-herlan.png",
"resume_url": "/api/resume",
"socials": [
{
"label": "Email",
"value": "alexwherlan@gmail.com",
"href": "mailto:alexwherlan@gmail.com",
"kind": "email"
},
{
"label": "LinkedIn",
"value": "alexander-herlan",
"href": "https://linkedin.com/in/alexander-herlan",
"kind": "linkedin"
},
{
"label": "GitHub",
"value": "AlexanderHerlan",
"href": "https://github.com/AlexanderHerlan",
"kind": "github"
}
],
"focus": ["Product engineering", "Applied AI", "Cloud systems"]
}
+41
View File
@@ -0,0 +1,41 @@
{
"intro": "A broad toolkit shaped by shipping products, supporting critical systems, and learning whatever the work requires.",
"principles": [
"Solve the operational problem, not just the ticket",
"Make complex systems feel understandable",
"Leave the codebase easier to change"
],
"categories": [
{
"name": "Languages & Web",
"description": "From service layers to responsive interfaces.",
"accent": "blue",
"items": ["Python", "JavaScript", "TypeScript", "Go", "PHP", "Java", "Kotlin", "C / C++", "HTML5", "CSS3", "Sass"]
},
{
"name": "Frameworks & Runtime",
"description": "Tools for building focused, maintainable products.",
"accent": "lavender",
"items": ["FastAPI", "Django", "Flask", "React", "Preact", "Angular", "RxJS", "Node.js", "Bun", "WordPress"]
},
{
"name": "AI & Data",
"description": "Practical machine intelligence and reliable data layers.",
"accent": "peach",
"items": ["TensorFlow Lite", "Computer Vision", "Natural Language Processing", "Scrapy", "PostgreSQL", "SQL Server", "MySQL", "MongoDB", "Redis"]
},
{
"name": "Cloud & Delivery",
"description": "Infrastructure that supports the product without stealing the show.",
"accent": "mint",
"items": ["AWS", "Azure DevOps", "Cloudflare Workers", "D1", "Durable Objects", "R2", "Docker", "Kubernetes", "Linux", "Git", "CI/CD"]
},
{
"name": "Product & Operations",
"description": "The connective work that turns software into an outcome.",
"accent": "yellow",
"items": ["System Architecture", "REST APIs", "Responsive Design", "UX Collaboration", "Technical Support", "IT Leadership", "Jira", "Google Workspace", "Microsoft 365"]
}
]
}
+196
View File
@@ -0,0 +1,196 @@
from __future__ import annotations
import json
import os
import re
import smtplib
import ssl
from email.message import EmailMessage
from functools import lru_cache
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel, EmailStr, Field
from starlette.concurrency import run_in_threadpool
BASE_DIR = Path(__file__).resolve().parent
PROJECT_DIR = BASE_DIR.parent
DATA_DIR = BASE_DIR / "data"
FRONTEND_DIST = PROJECT_DIR / "frontend" / "dist"
RESUME_PATH = PROJECT_DIR / "Alexander Herlan Resume 2024.pdf"
app = FastAPI(
title="Alex Herlan Portfolio API",
description="JSON-backed content and contact delivery for Alex Herlan's portfolio.",
version="1.0.0",
)
origins = [
origin.strip()
for origin in os.getenv("FRONTEND_ORIGINS", "http://localhost:5173").split(",")
if origin.strip()
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
class ContactPayload(BaseModel):
name: str = Field(min_length=2, max_length=100)
email: EmailStr
subject: str = Field(min_length=3, max_length=160)
message: str = Field(min_length=20, max_length=5000)
company: str = Field(default="", max_length=200)
@lru_cache(maxsize=8)
def read_data(filename: str) -> Any:
path = DATA_DIR / filename
if not path.is_file():
raise HTTPException(status_code=500, detail=f"Missing data file: {filename}")
return json.loads(path.read_text(encoding="utf-8"))
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/profile")
def profile() -> dict[str, Any]:
return read_data("profile.json")
@app.get("/api/experience")
def experience() -> list[dict[str, Any]]:
return read_data("experience.json")
@app.get("/api/skills")
def skills() -> dict[str, Any]:
return read_data("skills.json")
@app.get("/api/posts")
def posts(
q: str | None = Query(default=None, max_length=100),
tag: str | None = Query(default=None, max_length=50),
) -> list[dict[str, Any]]:
all_posts = read_data("posts.json")
query = q.casefold().strip() if q else None
requested_tag = tag.casefold().strip() if tag else None
filtered: list[dict[str, Any]] = []
for post in all_posts:
searchable = " ".join(
[post["title"], post["excerpt"], " ".join(post["tags"])]
).casefold()
if query and query not in searchable:
continue
if requested_tag and requested_tag not in {
post_tag.casefold() for post_tag in post["tags"]
}:
continue
filtered.append({key: value for key, value in post.items() if key != "content"})
return sorted(filtered, key=lambda item: item["published_at"], reverse=True)
@app.get("/api/posts/{slug}")
def post_by_slug(slug: str) -> dict[str, Any]:
if not re.fullmatch(r"[a-z0-9-]+", slug):
raise HTTPException(status_code=404, detail="Post not found")
for post in read_data("posts.json"):
if post["slug"] == slug:
return post
raise HTTPException(status_code=404, detail="Post not found")
@app.get("/api/resume")
def resume() -> FileResponse:
if not RESUME_PATH.is_file():
raise HTTPException(status_code=404, detail="Resume file not found")
return FileResponse(
RESUME_PATH,
media_type="application/pdf",
filename="Alexander-Herlan-Resume.pdf",
content_disposition_type="inline",
)
def deliver_contact_email(payload: ContactPayload) -> None:
smtp_host = os.getenv("SMTP_HOST")
smtp_port = int(os.getenv("SMTP_PORT", "587"))
smtp_username = os.getenv("SMTP_USERNAME")
smtp_password = os.getenv("SMTP_PASSWORD")
from_email = os.getenv("SMTP_FROM_EMAIL") or smtp_username
to_email = os.getenv("CONTACT_TO_EMAIL", "alexwherlan@gmail.com")
use_tls = os.getenv("SMTP_USE_TLS", "true").casefold() == "true"
use_ssl = os.getenv("SMTP_USE_SSL", "false").casefold() == "true"
if not smtp_host or not from_email:
raise RuntimeError("SMTP delivery is not configured")
message = EmailMessage()
message["Subject"] = f"Portfolio inquiry: {payload.subject}"
message["From"] = from_email
message["To"] = to_email
message["Reply-To"] = str(payload.email)
message.set_content(
f"New portfolio inquiry\n\n"
f"From: {payload.name} <{payload.email}>\n"
f"Subject: {payload.subject}\n\n"
f"{payload.message}"
)
context = ssl.create_default_context()
smtp_class = smtplib.SMTP_SSL if use_ssl else smtplib.SMTP
with smtp_class(smtp_host, smtp_port, timeout=20) as client:
if use_tls and not use_ssl:
client.starttls(context=context)
if smtp_username and smtp_password:
client.login(smtp_username, smtp_password)
client.send_message(message)
@app.post("/api/contact", status_code=202)
async def contact(payload: ContactPayload) -> dict[str, str]:
# A filled hidden field is almost certainly an automated submission. Respond
# normally so bots do not learn how the trap works.
if payload.company:
return {"status": "accepted", "message": "Thanks — your note is on its way."}
try:
await run_in_threadpool(deliver_contact_email, payload)
except (OSError, RuntimeError, smtplib.SMTPException) as exc:
raise HTTPException(
status_code=503,
detail="Email delivery is temporarily unavailable. Please email Alex directly.",
) from exc
return {"status": "accepted", "message": "Thanks — your note is on its way."}
if FRONTEND_DIST.is_dir():
@app.get("/{path:path}", include_in_schema=False)
def frontend(path: str) -> FileResponse:
requested_file = (FRONTEND_DIST / path).resolve()
try:
requested_file.relative_to(FRONTEND_DIST.resolve())
except ValueError:
raise HTTPException(status_code=404, detail="Not found")
if requested_file.is_file():
return FileResponse(requested_file)
return FileResponse(FRONTEND_DIST / "index.html")
+4
View File
@@ -0,0 +1,4 @@
fastapi
uvicorn[standard]
email-validator
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Alex Herlan is a senior software engineer building full-stack products, applied AI, and dependable cloud systems."
/>
<meta name="theme-color" content="#f7f8f4" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect width=%22100%22 height=%22100%22 rx=%2230%22 fill=%22%232f5147%22/><text x=%2250%22 y=%2264%22 text-anchor=%22middle%22 font-size=%2244%22 fill=%22white%22 font-family=%22Arial%22>AH</text></svg>" />
<title>Alex Herlan — Software Engineer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+948
View File
@@ -0,0 +1,948 @@
{
"name": "alex-herlan-portfolio",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "alex-herlan-portfolio",
"version": "1.0.0",
"dependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router-dom": "^7.18.3"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.1.1",
"lightningcss-win32-x64-msvc": "^1.33.0",
"vite": "^8.3.0"
}
},
"node_modules/@oxc-project/types": {
"version": "0.149.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
"integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz",
"integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz",
"integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz",
"integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz",
"integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz",
"integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz",
"integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz",
"integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz",
"integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz",
"integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz",
"integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz",
"integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz",
"integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz",
"integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz",
"integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz",
"integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
"babel-plugin-react-compiler": "^1.0.0",
"oxc-transform-react": "^0.145.0",
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"@rolldown/plugin-babel": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"oxc-transform-react": {
"optional": true
}
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/nanoid": {
"version": "3.3.19",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.18",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.28.0"
},
"peerDependencies": {
"react": "^19.3.0"
}
},
"node_modules/react-router": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rolldown": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz",
"integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.149.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.8",
"@rolldown/binding-android-arm64": "1.2.8",
"@rolldown/binding-darwin-arm64": "1.2.8",
"@rolldown/binding-darwin-x64": "1.2.8",
"@rolldown/binding-freebsd-x64": "1.2.8",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.8",
"@rolldown/binding-linux-arm64-gnu": "1.2.8",
"@rolldown/binding-linux-arm64-musl": "1.2.8",
"@rolldown/binding-linux-ppc64-gnu": "1.2.8",
"@rolldown/binding-linux-s390x-gnu": "1.2.8",
"@rolldown/binding-linux-x64-gnu": "1.2.8",
"@rolldown/binding-linux-x64-musl": "1.2.8",
"@rolldown/binding-openharmony-arm64": "1.2.8",
"@rolldown/binding-win32-arm64-msvc": "1.2.8",
"@rolldown/binding-win32-x64-msvc": "1.2.8"
}
},
"node_modules/scheduler": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT"
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/vite": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.28",
"rolldown": "~1.2.6",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.7.1",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "alex-herlan-portfolio",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router-dom": "^7.18.3"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.1.1",
"lightningcss-win32-x64-msvc": "^1.33.0",
"vite": "^8.3.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

+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
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
},
},
},
});