Initial website was built base on the context
This commit is contained in:
@@ -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>
|
||||
|
||||
Generated
+948
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 didn’t load.</strong>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user