Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c79b31a9 | ||
|
|
b37dd7ee3c | ||
|
|
9a399aba61 | ||
|
|
cb88c84777 | ||
|
|
6cbe8ae739 | ||
|
|
6b139c9901 | ||
|
|
f400f63321 | ||
|
|
bdacc4a895 | ||
|
|
821c8ae6a2 | ||
|
|
97a6042782 | ||
|
|
19cce7d75e | ||
|
|
1ba1c3123d | ||
|
|
5e15524ace | ||
|
|
4f47a19fd7 | ||
|
|
0579aa2448 | ||
|
|
b1ec646587 | ||
|
|
03b3e958cb | ||
|
|
23a7c03da5 | ||
|
|
ca28583823 | ||
|
|
c0ec5cf2b2 | ||
|
|
b103da0ad7 | ||
|
|
09411c50cc | ||
|
|
78393c24a1 | ||
|
|
7f328ae3ea | ||
|
|
59184d9164 | ||
|
|
0dccb99b76 | ||
|
|
698b76fc86 | ||
|
|
931ae86e7d | ||
|
|
9dbf8e4d6a | ||
|
|
25dfe21097 | ||
|
|
d056462f81 | ||
|
|
c087d42a1c | ||
|
|
464da42e38 | ||
|
|
688f929e01 | ||
|
|
6cd828130a | ||
|
|
8e93d167d3 | ||
|
|
5940147513 | ||
|
|
580be42af4 | ||
|
|
db01f865f1 | ||
|
|
dcd35386ae | ||
|
|
4d34520e19 | ||
|
|
5dca84ba70 | ||
|
|
79dac26536 | ||
|
|
2578befb0e | ||
|
|
2da4afe11b | ||
|
|
ff7422fb3f | ||
|
|
31e7b33c40 | ||
|
|
2d004d934a | ||
|
|
953c11bf09 | ||
|
|
354471daf6 | ||
|
|
ce362ce65a | ||
|
|
22631513cd | ||
|
|
852c2249a9 | ||
|
|
88b81063ca | ||
|
|
9503b0501c | ||
|
|
4fa24c7c18 | ||
|
|
d6c6e487ff |
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"],
|
|
||||||
"rules": {
|
|
||||||
"@typescript-eslint/no-unused-vars": "warn",
|
|
||||||
"@typescript-eslint/no-explicit-any": "warn",
|
|
||||||
"react-hooks/exhaustive-deps": "warn",
|
|
||||||
"react/no-unescaped-entities": "off",
|
|
||||||
"@next/next/no-img-element": "warn",
|
|
||||||
"jsx-a11y/alt-text": "warn"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: Deploy Vite site to Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
cache: npm
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v5
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: ./dist
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
# Sample workflow for building and deploying a Next.js site to GitHub Pages
|
|
||||||
#
|
|
||||||
# To get started with Next.js see: https://nextjs.org/docs/getting-started
|
|
||||||
#
|
|
||||||
name: Deploy Next.js site to Pages
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Runs on pushes targeting the default branch
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
# Allows you to run this workflow manually from the Actions tab
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
|
||||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
|
||||||
concurrency:
|
|
||||||
group: "pages"
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# Build job
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Detect package manager
|
|
||||||
id: detect-package-manager
|
|
||||||
run: |
|
|
||||||
if [ -f "${{ github.workspace }}/yarn.lock" ]; then
|
|
||||||
echo "manager=yarn" >> $GITHUB_OUTPUT
|
|
||||||
echo "command=install" >> $GITHUB_OUTPUT
|
|
||||||
echo "runner=yarn" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
elif [ -f "${{ github.workspace }}/package.json" ]; then
|
|
||||||
echo "manager=npm" >> $GITHUB_OUTPUT
|
|
||||||
echo "command=ci" >> $GITHUB_OUTPUT
|
|
||||||
echo "runner=npx --no-install" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo "Unable to determine package manager"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20"
|
|
||||||
cache: ${{ steps.detect-package-manager.outputs.manager }}
|
|
||||||
- name: Setup Pages
|
|
||||||
uses: actions/configure-pages@v5
|
|
||||||
with:
|
|
||||||
# Automatically inject basePath in your Next.js configuration file and disable
|
|
||||||
# server side image optimization (https://nextjs.org/docs/api-reference/next/image#unoptimized).
|
|
||||||
#
|
|
||||||
# You may remove this line if you want to manage the configuration yourself.
|
|
||||||
static_site_generator: next
|
|
||||||
- name: Restore cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
.next/cache
|
|
||||||
# Generate a new cache whenever packages or source files change.
|
|
||||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
|
|
||||||
# If source files changed but packages didn't, rebuild from a prior cache.
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}-
|
|
||||||
- name: Install dependencies
|
|
||||||
run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }}
|
|
||||||
- name: Build with Next.js
|
|
||||||
run: ${{ steps.detect-package-manager.outputs.runner }} next build
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: ./out
|
|
||||||
|
|
||||||
# Deployment job
|
|
||||||
deploy:
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build
|
|
||||||
steps:
|
|
||||||
- name: Deploy to GitHub Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
+4
-10
@@ -9,12 +9,8 @@
|
|||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
# next.js
|
# vite
|
||||||
/.next/
|
/dist/
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -28,9 +24,7 @@ yarn-error.log*
|
|||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
|
||||||
|
/resume
|
||||||
|
|||||||
Generated
-5
@@ -1,5 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<profile version="1.0">
|
|
||||||
<option name="myName" value="Project Default" />
|
|
||||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
|
||||||
</profile>
|
|
||||||
</component>
|
|
||||||
Generated
-8
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/personalwebsite.iml" filepath="$PROJECT_DIR$/.idea/personalwebsite.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
Generated
-12
@@ -1,12 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="WEB_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager">
|
|
||||||
<content url="file://$MODULE_DIR$">
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/temp" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
|
||||||
</content>
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "c1692715",
|
||||||
|
"configHash": "46535a3e",
|
||||||
|
"lockfileHash": "e3b0c442",
|
||||||
|
"browserHash": "7d791f90",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
First, run the development server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
|
||||||
|
|
||||||
## Learn More
|
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
Binary file not shown.
-464
@@ -1,464 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
||||||
:root{
|
|
||||||
--stone:#E8E5DF;
|
|
||||||
--stone2:#DDD9D2;
|
|
||||||
--stone3:#D4D0C9;
|
|
||||||
--navy:#1C2033;
|
|
||||||
--navy2:#252A42;
|
|
||||||
--mid:#626880;
|
|
||||||
--muted:#959BAF;
|
|
||||||
--line:rgba(28,32,51,0.11);
|
|
||||||
--line2:rgba(28,32,51,0.18);
|
|
||||||
--line3:rgba(28,32,51,0.26);
|
|
||||||
--g1:240px;
|
|
||||||
--g2:calc(100% - 480px);
|
|
||||||
}
|
|
||||||
html{scroll-behavior:smooth}
|
|
||||||
body{
|
|
||||||
background:var(--stone);
|
|
||||||
color:var(--navy);
|
|
||||||
font-family:'DM Sans',system-ui,sans-serif;
|
|
||||||
font-size:15px;
|
|
||||||
line-height:1.6;
|
|
||||||
-webkit-font-smoothing:antialiased;
|
|
||||||
overflow-x:hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.col-rule-l,.col-rule-r{
|
|
||||||
position:fixed;top:0;bottom:0;width:1px;
|
|
||||||
background:var(--line2);z-index:200;pointer-events:none;
|
|
||||||
}
|
|
||||||
.col-rule-l{left:var(--g1)}
|
|
||||||
.col-rule-r{right:var(--g1)}
|
|
||||||
.col-rule-c1{
|
|
||||||
position:fixed;top:0;bottom:0;width:1px;
|
|
||||||
background:var(--line);z-index:200;pointer-events:none;
|
|
||||||
left:calc(var(--g1) + var(--g2)/3);display:none;
|
|
||||||
}
|
|
||||||
.col-rule-c2{
|
|
||||||
position:fixed;top:0;bottom:0;width:1px;
|
|
||||||
background:var(--line);z-index:200;pointer-events:none;
|
|
||||||
left:calc(var(--g1) + var(--g2)*2/3);display:none;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav{
|
|
||||||
position:sticky;top:0;z-index:300;
|
|
||||||
background:var(--stone);
|
|
||||||
border-bottom:1px solid var(--line3);
|
|
||||||
display:flex;align-items:stretch;
|
|
||||||
height:52px;
|
|
||||||
}
|
|
||||||
.nav-logo-wrap{
|
|
||||||
width:var(--g1);flex-shrink:0;
|
|
||||||
display:flex;align-items:center;
|
|
||||||
padding:0 28px;
|
|
||||||
border-right:1px solid var(--line3);
|
|
||||||
gap:9px;
|
|
||||||
text-decoration:none;color:var(--navy);
|
|
||||||
}
|
|
||||||
.nav-logosvg{width:22px;height:22px}
|
|
||||||
.nav-name{
|
|
||||||
font-family:'Syne',sans-serif;
|
|
||||||
font-size:14px;font-weight:700;
|
|
||||||
letter-spacing:-.01em;
|
|
||||||
}
|
|
||||||
.nav-name sup{
|
|
||||||
font-size:8px;font-weight:500;
|
|
||||||
opacity:.5;vertical-align:super;
|
|
||||||
letter-spacing:.06em;font-family:'DM Sans',sans-serif;
|
|
||||||
}
|
|
||||||
.nav-links{display:flex;align-items:stretch;flex:1}
|
|
||||||
.nav-links a{
|
|
||||||
display:flex;align-items:center;
|
|
||||||
padding:0 22px;
|
|
||||||
font-size:13px;color:var(--mid);
|
|
||||||
text-decoration:none;
|
|
||||||
border-right:1px solid var(--line);
|
|
||||||
transition:color .15s;
|
|
||||||
font-weight:400;
|
|
||||||
letter-spacing:.01em;
|
|
||||||
}
|
|
||||||
.nav-links a:hover{color:var(--navy)}
|
|
||||||
.nav-socials{
|
|
||||||
display:flex;align-items:stretch;
|
|
||||||
width:var(--g1);
|
|
||||||
border-left:1px solid var(--line3);
|
|
||||||
}
|
|
||||||
.nav-socials a{
|
|
||||||
display:flex;align-items:center;justify-content:center;
|
|
||||||
padding:0 22px;
|
|
||||||
color:var(--mid);text-decoration:none;
|
|
||||||
border-left:1px solid var(--line);
|
|
||||||
transition:color .15s;
|
|
||||||
}
|
|
||||||
.nav-socials a:hover{color:var(--navy)}
|
|
||||||
.nav-socials svg{width:16px;height:16px;fill:currentColor}
|
|
||||||
|
|
||||||
.strip{
|
|
||||||
background:#D0CCC5;
|
|
||||||
border-bottom:1px solid var(--line3);
|
|
||||||
display:flex;align-items:center;justify-content:center;gap:12px;
|
|
||||||
padding:9px 24px;font-size:12.5px;
|
|
||||||
color:var(--mid);
|
|
||||||
}
|
|
||||||
.strip-pill{
|
|
||||||
background:var(--navy);color:var(--stone);
|
|
||||||
border-radius:20px;padding:2px 10px;
|
|
||||||
font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;
|
|
||||||
}
|
|
||||||
.strip a{color:var(--navy);text-decoration:none;font-weight:500;display:flex;align-items:center;gap:5px}
|
|
||||||
.strip-arr{opacity:.45;font-size:14px}
|
|
||||||
|
|
||||||
.row{
|
|
||||||
display:grid;
|
|
||||||
grid-template-columns:var(--g1) 1fr var(--g1);
|
|
||||||
border-bottom:1px solid var(--line3);
|
|
||||||
}
|
|
||||||
.row-inner{padding:0}
|
|
||||||
.row-gutter-l{border-right:1px solid var(--line3)}
|
|
||||||
.row-gutter-r{border-left:1px solid var(--line3)}
|
|
||||||
|
|
||||||
.hero-row{
|
|
||||||
display:grid;
|
|
||||||
grid-template-columns:var(--g1) 1fr 300px var(--g1);
|
|
||||||
border-bottom:1px solid var(--line3);
|
|
||||||
min-height:340px;
|
|
||||||
}
|
|
||||||
.hero-gutter{border-right:1px solid var(--line3)}
|
|
||||||
.hero-gutter-r{border-left:1px solid var(--line3)}
|
|
||||||
.hero-main{
|
|
||||||
padding:60px 52px 52px;
|
|
||||||
border-right:1px solid var(--line3);
|
|
||||||
}
|
|
||||||
.status-row{
|
|
||||||
display:inline-flex;align-items:center;gap:8px;
|
|
||||||
font-size:12px;color:var(--mid);font-weight:500;
|
|
||||||
letter-spacing:.03em;margin-bottom:22px;
|
|
||||||
}
|
|
||||||
.green-dot{width:7px;height:7px;border-radius:50%;background:#4CAF7D;position:relative;flex-shrink:0}
|
|
||||||
.green-dot::after{content:'';position:absolute;inset:-3px;border-radius:50%;background:rgba(76,175,125,.2);animation:gpulse 2.2s ease-in-out infinite}
|
|
||||||
@keyframes gpulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.7);opacity:0}}
|
|
||||||
.hero-name{
|
|
||||||
font-family:'Syne',sans-serif;
|
|
||||||
font-size:clamp(36px,4vw,56px);
|
|
||||||
font-weight:700;
|
|
||||||
line-height:1.03;
|
|
||||||
letter-spacing:-.035em;
|
|
||||||
color:var(--navy);
|
|
||||||
margin-bottom:6px;
|
|
||||||
}
|
|
||||||
.hero-role{
|
|
||||||
font-family:'DM Sans',sans-serif;
|
|
||||||
font-size:clamp(14px,1.5vw,16px);
|
|
||||||
color:var(--mid);
|
|
||||||
font-weight:300;
|
|
||||||
font-style:italic;
|
|
||||||
letter-spacing:.01em;
|
|
||||||
margin-bottom:22px;
|
|
||||||
}
|
|
||||||
.hero-lead{
|
|
||||||
font-size:14.5px;color:var(--mid);line-height:1.68;
|
|
||||||
max-width:500px;margin-bottom:36px;font-weight:300;
|
|
||||||
}
|
|
||||||
.hero-thesis{
|
|
||||||
font-size:13.5px;color:var(--navy);line-height:1.55;
|
|
||||||
max-width:480px;margin-bottom:16px;font-weight:500;
|
|
||||||
font-style:italic;
|
|
||||||
}
|
|
||||||
.now-line{
|
|
||||||
display:inline-flex;align-items:flex-start;gap:7px;
|
|
||||||
font-size:12px;color:var(--mid);line-height:1.5;
|
|
||||||
max-width:480px;margin-bottom:20px;
|
|
||||||
padding:8px 12px;background:var(--stone2);
|
|
||||||
border:1px solid var(--line3);border-radius:4px;
|
|
||||||
}
|
|
||||||
.now-line strong{color:var(--navy);font-weight:600}
|
|
||||||
.now-dot{color:#4CAF7D;font-size:8px;flex-shrink:0;margin-top:4px}
|
|
||||||
.status-badge{
|
|
||||||
display:inline-flex;align-items:center;gap:7px;
|
|
||||||
margin-bottom:22px;text-decoration:none;
|
|
||||||
font-size:12px;color:var(--mid);font-family:'DM Mono',monospace;
|
|
||||||
}
|
|
||||||
.status-badge img{height:20px;display:block;border-radius:2px}
|
|
||||||
.status-badge span{border-bottom:1px solid var(--line3)}
|
|
||||||
.status-badge:hover span{color:var(--navy);border-color:var(--line2)}
|
|
||||||
.btns{display:flex;gap:9px;flex-wrap:wrap}
|
|
||||||
.btn{
|
|
||||||
display:inline-flex;align-items:center;gap:7px;
|
|
||||||
border-radius:3px;padding:8px 16px;
|
|
||||||
font-size:13px;font-weight:500;font-family:'DM Sans',sans-serif;
|
|
||||||
text-decoration:none;cursor:pointer;border:none;
|
|
||||||
transition:all .14s;letter-spacing:.01em;
|
|
||||||
}
|
|
||||||
.btn-d{background:var(--navy);color:var(--stone)}
|
|
||||||
.btn-d:hover{background:var(--navy2)}
|
|
||||||
.btn-o{background:transparent;color:var(--navy);border:1px solid var(--line3)}
|
|
||||||
.btn-o:hover{background:var(--stone2)}
|
|
||||||
.hero-stats{padding:32px 28px;display:flex;flex-direction:column}
|
|
||||||
.stat{padding:15px 0;border-bottom:1px solid var(--line);display:flex;gap:11px;align-items:flex-start}
|
|
||||||
.stat:first-child{border-top:1px solid var(--line)}
|
|
||||||
.stat-ico{font-size:15px;opacity:.35;flex-shrink:0;margin-top:2px}
|
|
||||||
.stat-val{font-size:13.5px;font-weight:600;color:var(--navy);letter-spacing:-.01em}
|
|
||||||
.stat-lbl{font-size:11px;color:var(--muted);margin-top:1px}
|
|
||||||
|
|
||||||
.sec{
|
|
||||||
display:grid;
|
|
||||||
grid-template-columns:var(--g1) 1fr var(--g1);
|
|
||||||
border-bottom:1px solid var(--line3);
|
|
||||||
}
|
|
||||||
.sec-g{border-right:1px solid var(--line3)}
|
|
||||||
.sec-gr{border-left:1px solid var(--line3)}
|
|
||||||
.sec-body{padding:48px 52px}
|
|
||||||
.slbl{
|
|
||||||
font-size:10.5px;font-weight:600;letter-spacing:.12em;
|
|
||||||
text-transform:uppercase;color:var(--muted);
|
|
||||||
margin-bottom:30px;font-family:'DM Mono',monospace;
|
|
||||||
}
|
|
||||||
.sec-h{
|
|
||||||
font-family:'Syne',sans-serif;font-weight:700;
|
|
||||||
font-size:clamp(22px,2.4vw,32px);
|
|
||||||
letter-spacing:-.025em;color:var(--navy);
|
|
||||||
margin-bottom:12px;line-height:1.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.about-split{
|
|
||||||
display:grid;
|
|
||||||
grid-template-columns:var(--g1) 1fr 1fr var(--g1);
|
|
||||||
border-bottom:1px solid var(--line3);
|
|
||||||
}
|
|
||||||
.about-g{border-right:1px solid var(--line3)}
|
|
||||||
.about-gr{border-left:1px solid var(--line3)}
|
|
||||||
.about-left{padding:44px 48px;border-right:1px solid var(--line3)}
|
|
||||||
.about-right{padding:44px 48px}
|
|
||||||
.terminal{background:var(--navy);border-radius:6px;overflow:hidden}
|
|
||||||
.t-bar{background:rgba(255,255,255,.05);padding:10px 15px;display:flex;align-items:center;gap:7px;border-bottom:1px solid rgba(255,255,255,.06)}
|
|
||||||
.td{width:10px;height:10px;border-radius:50%}
|
|
||||||
.td.r{background:#FF5F57}.td.y{background:#FEBC2E}.td.g{background:#28C840}
|
|
||||||
.t-title{flex:1;text-align:center;font-size:10.5px;color:rgba(255,255,255,.2);font-family:'DM Mono',monospace}
|
|
||||||
.t-body{
|
|
||||||
padding:18px 20px;
|
|
||||||
font-family:'DM Mono',monospace;
|
|
||||||
font-size:13px;line-height:1.85;
|
|
||||||
color:rgba(232,229,223,.75);
|
|
||||||
}
|
|
||||||
.t-p{color:#5FCE8A}.t-c{color:rgba(232,229,223,.35)}
|
|
||||||
.t-k{color:#82B4D0}.t-v{color:rgba(232,229,223,.8)}
|
|
||||||
.cur{display:inline-block;width:7px;height:12px;background:rgba(232,229,223,.5);vertical-align:middle;animation:bl 1s step-end infinite}
|
|
||||||
@keyframes bl{0%,100%{opacity:1}50%{opacity:0}}
|
|
||||||
|
|
||||||
.t-stage{position:relative;min-height:400px}
|
|
||||||
.t-pane{position:absolute;inset:0;opacity:1;transition:opacity .55s ease}
|
|
||||||
.t-pane.t-hidden{opacity:0;pointer-events:none}
|
|
||||||
.t-cmd-row{display:flex;align-items:center;gap:6px;margin-top:2px}
|
|
||||||
.t-input{background:transparent;border:none;outline:none;color:rgba(232,229,223,.85);font:inherit;flex:1;caret-color:#5FCE8A}
|
|
||||||
.t-hint{color:rgba(232,229,223,.28);font-size:11px;margin-top:10px}
|
|
||||||
.t-pane.t-egg-pane{
|
|
||||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
|
||||||
text-align:center;gap:12px;padding:18px 20px;
|
|
||||||
opacity:0;pointer-events:none;
|
|
||||||
}
|
|
||||||
.t-pane.t-egg-pane.t-egg-show{opacity:1;pointer-events:auto}
|
|
||||||
.t-ascii-scroll{max-width:100%;overflow-x:auto}
|
|
||||||
.t-ascii{white-space:pre;margin:0;line-height:1.05;font-family:'DM Mono',monospace}
|
|
||||||
.t-ascii-moria{
|
|
||||||
font-size:clamp(5px,2.1vw,8.5px);
|
|
||||||
color:#7ee7ff;
|
|
||||||
text-shadow:0 0 4px #7ee7ff,0 0 14px rgba(126,231,255,.55),0 0 28px rgba(126,231,255,.3);
|
|
||||||
animation:t-pulse-cyan 2.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.t-ascii-balrog{
|
|
||||||
font-size:clamp(3px,1vw,4.8px);
|
|
||||||
color:#ff7a3d;
|
|
||||||
text-shadow:0 0 4px #ff7a3d,0 0 14px rgba(255,122,61,.6),0 0 30px rgba(255,80,20,.35);
|
|
||||||
animation:t-pulse-fire 1.4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
@keyframes t-pulse-cyan{0%,100%{filter:brightness(1)}50%{filter:brightness(1.35)}}
|
|
||||||
@keyframes t-pulse-fire{0%,100%{filter:brightness(1) hue-rotate(0deg)}50%{filter:brightness(1.4) hue-rotate(-6deg)}}
|
|
||||||
.t-egg-cap{font-size:11px;letter-spacing:.02em;font-family:'DM Mono',monospace;color:rgba(232,229,223,.45)}
|
|
||||||
.t-egg-cap-moria{color:#a9e9f7}
|
|
||||||
.t-egg-cap-balrog{color:#ffb08a}
|
|
||||||
.about-right p{font-size:14px;color:var(--mid);line-height:1.72;font-weight:300;margin-bottom:14px}
|
|
||||||
.about-right strong{color:var(--navy);font-weight:500}
|
|
||||||
|
|
||||||
.sk4{
|
|
||||||
display:grid;grid-template-columns:repeat(4,1fr);
|
|
||||||
border:1px solid var(--line3);border-radius:5px;
|
|
||||||
overflow:hidden;background:var(--line);
|
|
||||||
}
|
|
||||||
.sk{
|
|
||||||
background:var(--stone);padding:22px 20px;
|
|
||||||
border-right:1px solid var(--line);border-bottom:1px solid var(--line);
|
|
||||||
transition:background .14s;overflow:hidden;
|
|
||||||
}
|
|
||||||
.sk:hover{background:var(--stone2)}
|
|
||||||
.sk-ico{font-size:17px;margin-bottom:10px;opacity:.38}
|
|
||||||
.sk-t{font-size:11.5px;font-weight:600;color:var(--navy);letter-spacing:.01em;margin-bottom:10px;font-family:'DM Mono',monospace}
|
|
||||||
.tags{display:flex;flex-wrap:wrap;gap:3px}
|
|
||||||
.tag{
|
|
||||||
background:rgba(28,32,51,.06);border:1px solid var(--line);
|
|
||||||
border-radius:2px;padding:2px 6px;
|
|
||||||
font-size:10.5px;color:var(--mid);
|
|
||||||
font-family:'DM Mono',monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hl3{
|
|
||||||
display:grid;grid-template-columns:repeat(3,1fr);
|
|
||||||
border:1px solid var(--line3);border-radius:5px;
|
|
||||||
overflow:hidden;background:var(--line);
|
|
||||||
margin-bottom:24px;
|
|
||||||
}
|
|
||||||
.hl{
|
|
||||||
background:var(--stone);padding:24px 20px;
|
|
||||||
border-right:1px solid var(--line);border-bottom:1px solid var(--line);
|
|
||||||
transition:background .14s;overflow:hidden;
|
|
||||||
}
|
|
||||||
.hl:hover{background:var(--stone2)}
|
|
||||||
.hl-hd{display:flex;align-items:center;gap:9px;margin-bottom:9px}
|
|
||||||
.hl-ico{font-size:18px;opacity:.4}
|
|
||||||
.hl-t{font-size:14px;font-weight:600;color:var(--navy);letter-spacing:-.01em}
|
|
||||||
.hl-d{font-size:12.5px;color:var(--mid);line-height:1.62;margin-bottom:11px;font-weight:300}
|
|
||||||
.specs{display:flex;flex-wrap:wrap;gap:3px}
|
|
||||||
.spec{background:rgba(28,32,51,.05);border:1px solid var(--line);border-radius:2px;padding:2px 6px;font-size:10.5px;color:var(--mid);font-family:'DM Mono',monospace}
|
|
||||||
|
|
||||||
.hw-table{width:100%;border-collapse:collapse;margin-bottom:24px;font-size:12.5px}
|
|
||||||
.hw-table th{text-align:left;font-weight:600;font-family:'DM Mono',monospace;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:8px 12px;border-bottom:1px solid var(--line3);background:var(--stone2)}
|
|
||||||
.hw-table td{padding:9px 12px;border-bottom:1px solid var(--line);color:var(--mid);vertical-align:top}
|
|
||||||
.hw-table td:first-child{color:var(--navy);font-weight:500;width:140px;min-width:100px;font-family:'DM Mono',monospace;font-size:11.5px}
|
|
||||||
.hw-table tr:last-child td{border-bottom:none}
|
|
||||||
.hw-table tr:hover td{background:var(--stone2)}
|
|
||||||
|
|
||||||
.note{
|
|
||||||
padding:16px 18px;
|
|
||||||
background:var(--stone2);border:1px solid var(--line3);border-radius:5px;
|
|
||||||
display:flex;gap:12px;align-items:flex-start;
|
|
||||||
}
|
|
||||||
.note-ico{font-size:16px;opacity:.38;flex-shrink:0;margin-top:2px}
|
|
||||||
.note-t{font-size:13px;font-weight:500;color:var(--navy);margin-bottom:2px}
|
|
||||||
.note-d{font-size:12.5px;color:var(--mid);line-height:1.6;font-weight:300}
|
|
||||||
|
|
||||||
.pr3{
|
|
||||||
display:grid;grid-template-columns:repeat(3,1fr);
|
|
||||||
border:1px solid var(--line3);border-radius:5px;
|
|
||||||
overflow:hidden;background:var(--line);
|
|
||||||
}
|
|
||||||
.pr{
|
|
||||||
background:var(--stone);padding:24px 20px;
|
|
||||||
border-right:1px solid var(--line);border-bottom:1px solid var(--line);
|
|
||||||
display:flex;flex-direction:column;
|
|
||||||
transition:background .14s;
|
|
||||||
}
|
|
||||||
.pr:hover{background:var(--stone2)}
|
|
||||||
.pr-type{font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:7px;font-family:'DM Mono',monospace}
|
|
||||||
.pr-t{font-size:14.5px;font-weight:600;color:var(--navy);letter-spacing:-.01em;margin-bottom:7px;line-height:1.3}
|
|
||||||
.pr-d{font-size:12.5px;color:var(--mid);line-height:1.62;flex:1;margin-bottom:14px;font-weight:300}
|
|
||||||
.pr-tags{display:flex;flex-wrap:wrap;gap:3px}
|
|
||||||
.pr-tag{background:rgba(28,32,51,.05);border:1px solid var(--line);border-radius:2px;padding:2px 6px;font-size:10.5px;color:var(--muted);font-family:'DM Mono',monospace}
|
|
||||||
.badge{display:inline-flex;align-items:center;gap:4px;border-radius:2px;padding:2px 7px;font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;margin-bottom:8px;font-family:'DM Mono',monospace}
|
|
||||||
.b-live{background:rgba(76,175,125,.1);color:#2A7A4F}
|
|
||||||
.b-dev{background:rgba(210,140,0,.1);color:#8A5E00}
|
|
||||||
.b-oss{background:rgba(28,32,51,.07);color:var(--mid)}
|
|
||||||
|
|
||||||
.gh-list{border:1px solid var(--line3);border-radius:5px;overflow:hidden}
|
|
||||||
.gh-item{
|
|
||||||
display:flex;align-items:center;justify-content:space-between;
|
|
||||||
padding:14px 18px;background:var(--stone);
|
|
||||||
border-bottom:1px solid var(--line);
|
|
||||||
transition:background .14s;gap:16px;
|
|
||||||
}
|
|
||||||
.gh-item:last-child{border-bottom:none}
|
|
||||||
.gh-item:hover{background:var(--stone2)}
|
|
||||||
.gh-n{font-size:13.5px;font-weight:500;color:var(--navy);margin-bottom:2px;font-family:'DM Mono',monospace}
|
|
||||||
.gh-d{font-size:12px;color:var(--mid);font-weight:300}
|
|
||||||
.gh-r{display:flex;align-items:center;gap:8px;flex-shrink:0}
|
|
||||||
.gh-lang{font-size:10.5px;font-family:'DM Mono',monospace;color:var(--muted);padding:2px 7px;border:1px solid var(--line);border-radius:2px}
|
|
||||||
.gh-stars{font-size:10.5px;font-family:'DM Mono',monospace;color:#B08A2E;padding:2px 7px;border:1px solid var(--line);border-radius:2px}
|
|
||||||
.gh-lnk{color:var(--mid);text-decoration:none;font-size:12px;font-family:'DM Mono',monospace;transition:color .14s}
|
|
||||||
.gh-lnk:hover{color:var(--navy)}
|
|
||||||
|
|
||||||
.acc{border-top:1px solid var(--line3)}
|
|
||||||
.acc-item{border-bottom:1px solid var(--line3)}
|
|
||||||
.acc-btn{
|
|
||||||
width:100%;background:none;border:none;
|
|
||||||
padding:20px 0;display:flex;align-items:flex-start;
|
|
||||||
justify-content:space-between;cursor:pointer;
|
|
||||||
text-align:left;font-family:inherit;gap:20px;
|
|
||||||
transition:background .15s;
|
|
||||||
}
|
|
||||||
.acc-btn:hover .acc-t{color:var(--navy2)}
|
|
||||||
.acc-l{flex:1}
|
|
||||||
.acc-t{font-size:16.5px;font-weight:500;color:var(--navy);letter-spacing:-.015em;line-height:1.25;margin-bottom:3px;transition:color .14s;font-family:'Syne',sans-serif}
|
|
||||||
.acc-s{font-size:12px;color:var(--muted);font-family:'DM Mono',monospace}
|
|
||||||
.acc-r2{display:flex;align-items:center;gap:12px;flex-shrink:0}
|
|
||||||
.acc-per{font-size:11.5px;color:var(--muted);font-family:'DM Mono',monospace}
|
|
||||||
.acc-chev{width:17px;height:17px;color:var(--muted);transition:transform .2s}
|
|
||||||
.acc-item.open .acc-chev{transform:rotate(180deg)}
|
|
||||||
.acc-body{display:none;padding:0 0 22px;color:var(--mid);font-size:13.5px;line-height:1.7;font-weight:300}
|
|
||||||
.acc-item.open .acc-body{display:block}
|
|
||||||
.acc-body ul{list-style:none;margin-top:10px;display:flex;flex-direction:column;gap:6px}
|
|
||||||
.acc-body li{display:flex;gap:11px;align-items:flex-start}
|
|
||||||
.acc-body li::before{content:'--';color:var(--muted);flex-shrink:0;font-family:'DM Mono',monospace;font-size:10px;margin-top:4px}
|
|
||||||
.econ-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:12px}
|
|
||||||
.econ-tag{background:rgba(28,32,51,.06);border:1px solid var(--line3);border-radius:2px;padding:3px 8px;font-size:11.5px;color:var(--navy);font-weight:500}
|
|
||||||
|
|
||||||
.langs{
|
|
||||||
display:grid;grid-template-columns:repeat(5,1fr);
|
|
||||||
border:1px solid var(--line3);border-radius:5px;overflow:hidden;
|
|
||||||
background:var(--line);margin-top:28px;
|
|
||||||
}
|
|
||||||
.langc{background:var(--stone);padding:16px 12px;text-align:center;border-right:1px solid var(--line)}
|
|
||||||
.langc:last-child{border-right:none}
|
|
||||||
.lang-n{font-size:13px;font-weight:600;color:var(--navy);margin-bottom:3px;font-family:'Syne',sans-serif}
|
|
||||||
.lang-l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em;font-family:'DM Mono',monospace}
|
|
||||||
|
|
||||||
.certs{display:flex;gap:9px;flex-wrap:wrap;margin-top:24px}
|
|
||||||
.cert{display:flex;align-items:center;gap:8px;border:1px solid var(--line3);border-radius:4px;padding:10px 14px;font-size:12.5px;color:var(--navy)}
|
|
||||||
|
|
||||||
.contact{
|
|
||||||
display:grid;
|
|
||||||
grid-template-columns:var(--g1) 1fr var(--g1);
|
|
||||||
background:var(--navy);border-top:1px solid var(--line3);
|
|
||||||
}
|
|
||||||
.contact-g{border-right:1px solid rgba(255,255,255,.07)}
|
|
||||||
.contact-gr{border-left:1px solid rgba(255,255,255,.07)}
|
|
||||||
.contact-body{padding:64px 52px}
|
|
||||||
.contact-body h2{
|
|
||||||
font-family:'Syne',sans-serif;font-weight:700;
|
|
||||||
font-size:clamp(28px,3.2vw,46px);letter-spacing:-.03em;
|
|
||||||
margin-bottom:12px;line-height:1.06;color:var(--stone);
|
|
||||||
}
|
|
||||||
.contact-body p{font-size:14px;color:rgba(232,229,223,.45);margin-bottom:32px;line-height:1.65;max-width:440px;font-weight:300}
|
|
||||||
.contact-ctas{display:flex;gap:9px;flex-wrap:wrap}
|
|
||||||
.btn-li{background:var(--stone);color:var(--navy);border-radius:3px;padding:8px 16px;font-size:13px;font-weight:500;font-family:inherit;text-decoration:none;transition:all .14s;word-break:break-all}
|
|
||||||
.btn-li:hover{background:#F0EDE7}
|
|
||||||
.btn-gh{background:transparent;color:rgba(232,229,223,.55);border:1px solid rgba(255,255,255,.14);border-radius:3px;padding:8px 16px;font-size:13px;font-weight:500;font-family:inherit;text-decoration:none;transition:all .14s}
|
|
||||||
.btn-gh:hover{border-color:rgba(255,255,255,.32);color:var(--stone)}
|
|
||||||
|
|
||||||
footer{
|
|
||||||
background:var(--navy);border-top:1px solid rgba(255,255,255,.06);
|
|
||||||
display:grid;grid-template-columns:var(--g1) 1fr var(--g1);
|
|
||||||
}
|
|
||||||
.foot{grid-column:2/3;display:flex;align-items:center;justify-content:space-between;padding:18px 52px}
|
|
||||||
footer p,footer a{font-size:12px;color:rgba(232,229,223,.24);text-decoration:none;font-family:'DM Mono',monospace}
|
|
||||||
|
|
||||||
@media(max-width:1100px){
|
|
||||||
:root{--g1:20px}
|
|
||||||
.col-rule-l,.col-rule-r,.col-rule-c1,.col-rule-c2{display:none}
|
|
||||||
.nav-socials{width:auto}
|
|
||||||
.hero-row{grid-template-columns:20px 1fr 260px 20px}
|
|
||||||
.sk4,.hl3,.pr3{grid-template-columns:repeat(2,1fr)}
|
|
||||||
.langs{grid-template-columns:repeat(3,1fr)}
|
|
||||||
}
|
|
||||||
@media(max-width:700px){
|
|
||||||
nav .nav-links{display:none}
|
|
||||||
.hero-row{grid-template-columns:16px 1fr 16px}
|
|
||||||
.hero-stats{display:none}
|
|
||||||
.about-split{grid-template-columns:16px 1fr 16px}
|
|
||||||
.about-left{display:none}
|
|
||||||
.sk4,.hl3,.pr3{grid-template-columns:1fr}
|
|
||||||
.langs{grid-template-columns:repeat(2,1fr)}
|
|
||||||
.sec-body,.about-right,.contact-body,.hero-main{padding:32px 20px}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import type { Metadata } from 'next'
|
|
||||||
import './globals.css'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Pietro Quintavalle',
|
|
||||||
description: 'Systems Engineer · Developer · Homelabber. Economics student at USI Lugano with 5+ years building production-grade infrastructure.',
|
|
||||||
keywords: ['Pietro Quintavalle', 'Systems Engineer', 'Homelab', 'SysAdmin', 'Economics', 'USI', 'Switzerland'],
|
|
||||||
authors: [{ name: 'Pietro Quintavalle' }],
|
|
||||||
openGraph: {
|
|
||||||
title: 'Pietro Quintavalle',
|
|
||||||
description: 'Systems Engineer · Developer · Homelabber',
|
|
||||||
siteName: 'Pietro Quintavalle',
|
|
||||||
type: 'website',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;1,9..40,300&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
||||||
</head>
|
|
||||||
<body>{children}</body>
|
|
||||||
</html>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-1292
File diff suppressed because it is too large
Load Diff
-646
@@ -1,646 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
const DURIN_ART = ` ______________ _ _,-----------._ ___
|
|
||||||
| | (_,.- _,-'_,-----------._\`-._ _)_)
|
|
||||||
| THE _ _ _ | | ,'_,-' ___________ \`-._\`.
|
|
||||||
| | / \\|_)| \\ | \`' ,',' _,-'___________\`-._ \`.\`.
|
|
||||||
| |__\\_/| \\|_/ | ,',' ,'_,-' . \`-._\`. \`.\`.
|
|
||||||
| | /,' ,',' >|< \`.\`. \`.\\
|
|
||||||
| OF THE _ _ | // ,',' >< ,^. >< \`.\`. \\\\
|
|
||||||
| |_)||\\|/_(_ | // /,' >< / | \\ >< \`.\\ \\\\
|
|
||||||
| | \\|| |\\_|_) | // // >< \\/\\^/\\/ >< \\\\ \\\\
|
|
||||||
|______________| ;; ;; \`---' :: ::
|
|
||||||
|| || (____ || ||
|
|
||||||
DOORS OF DURIN _||__||_ ,'----. _||__||_
|
|
||||||
(o.____.o)____ \`---' ____(o.____.o)
|
|
||||||
| | /,--.) (,--.\\ | |
|
|
||||||
| |(( -\`___ ___\` ))| |
|
|
||||||
| | \\\\,'', \`. .' .\`\`.// | |
|
|
||||||
| | // (___,'. .'.___) \\\\ | |
|
|
||||||
/| | ;;)) ____) . . (____ ((\\\\ | |\\
|
|
||||||
\\|.__ | ||/ .'.--.\\/ \`/,--.\`. \\;: | __,|;
|
|
||||||
|\`-,\`;.| :/ /,' \`)-' \`-(' \`.\\ \\: |.;',-'|
|
|
||||||
| \`.. ' / \\__.' \`.__/ \\ \` ,.' |
|
|
||||||
| |,\\ /, ,\\ /,| |
|
|
||||||
| ||: : ) . ( : :|| |
|
|
||||||
/| |:; |/ . ./|\\, , \\| :;| |\\
|
|
||||||
\\|.__ |/ : ,/- <--:--> ,\\. ; \\| __,|;
|
|
||||||
|\`-.\`\`: \`'/-. '\\|/\` ,-\\\`; ;'',-'|
|
|
||||||
| \`.. ,' \`' ' \` \`. ,.' |
|
|
||||||
| || : : || |
|
|
||||||
| || | | || |
|
|
||||||
| || | | || |
|
|
||||||
| |' | _ | \`| |
|
|
||||||
| | | '|)) | | |
|
|
||||||
;____: \`._ \`' _,' ;____:
|
|
||||||
{______} \\___________________/ {______}
|
|
||||||
SSt |______|_______________________________|______|`
|
|
||||||
|
|
||||||
const BALROG_ART = `############################+##++++++########+++++-----. ------------ . - --.--- --.--.------...---------------..---
|
|
||||||
###############################+++++++####+++++----- --- - ---- -- -. ..- - - -------------------------------
|
|
||||||
##########################+++++++++++++#####+++---. . -- - - - - .--.-----------------------------
|
|
||||||
########################+++#+++++++++######++++-. ..--++++++------..----- - ------------.- ---------
|
|
||||||
###########################+##+++++++++++##++- -++++++++--+--------.- .. ...-.. - ----------------.---------
|
|
||||||
############################+++++++++++++#++-+ - -++++++++++++------+++---++-------------. -.----------.--------
|
|
||||||
#############################++++++++++++#++++- --- ++++++++++++++++--+++++++------------------------- -------------.---
|
|
||||||
#############################++++++++++++++++--- .+##+++++++++++++++++++++++++---------------------------.----------------
|
|
||||||
############++#+###+#+++#####++++++++++++++-- --- -- -+###+++++++++++++++++++++++++----+---------------------------------------
|
|
||||||
###########++++++++++++#++++++++++++++--..---- . .-- .. -+#####++++++++++++++++++++++++-+--++++-------------------------------------
|
|
||||||
############+++++++++++++++++++++++++++++++++.+ ..--.- +#########+++++++++++++++++++++++++++----+------------------------.-----------
|
|
||||||
############++++++++++++++++++++++-+++++++++++++.. + .++#####++#++++#++++++++++++++++++++++++++-------+--------------------- ---------
|
|
||||||
########+#+++++++++++++++++++++---++++-+++++++++.. -+#########-+ .+++++++++++++++++++++++-++++-+++++----+-------------------+--------
|
|
||||||
+######+#+++++++++++++++++++++-+++++++-++++++++++##+-.+++++++#+ +++++++++++++++++++++++-----+++++++++++-----+--------+-------------
|
|
||||||
++##+++++++++++++++++++++++++++++++++++++-+++.++++-+ .+++++-++++++++++++++++++++++----+---------------+++++++-----+----------------
|
|
||||||
++++++++++++++++++++++++++##+++++++++++++++. ++# ++ +-.++++++++++++++++#++++---+------------------------+-+++---+++-+---+-----.----
|
|
||||||
+++++++++++++++++++++++###++++++++++++++++++-+++- +++ ++++++++++++++#++++-++----+-+----------------------+---++++++++----+--++++----
|
|
||||||
+++++++++++++++++++++###++++++++++++++++++++++++ +#+#+#+#+++#######+++#----------------------------------+-++----++-+++--+++++++----.
|
|
||||||
+++++++++++++++++++++++#++++++++++++-++++++++++##++++#++#+++#+++#+#+++++++++--+----------+------------------+-++--++-+--++++++-++-.---
|
|
||||||
++++++++++++++++++++++++++++++++++++++++++#+++++-++++##+#++##++####+++++++++++-+-----------+------------------+---++++++++++++++-++---
|
|
||||||
+++++++++++++++++++++++++++++++++++++++--++++++#++++###+#++#+#++++++++++++++++++++---+---------------------------+++++++++++++++++++--
|
|
||||||
+++++++++++++++++++++++++++++-------++--+++++#++++#+#######+#++++#+#-++-+++++++++++------------------------------+--++++++++++++++++--
|
|
||||||
+++++++++++++++++++++++++++-------+-----+#+++#+++# #.# #+####+###++--+++++++++++++--------------.-------------+-++--++++++++++++++--
|
|
||||||
+++++++++++++++++++++++-----------------+++#+--# . - - .+####+##++-----++++++++--+----+-+--------.--------------+-----+++++++++++++--
|
|
||||||
+++++++++++++++++++++-------++----------#++++# - #. ####+-+-.+--+++++++---+++++++----------------------------.--++++++++++++--
|
|
||||||
+++++++++++++--+++++-+-------+----------+++##. . + +#+- -++---+ ++++----+++++++++---------------------------..+++++++++++++--
|
|
||||||
+++++++++++++-++++++---------------------#++ ---. +#- +++-. -++----++-+-+-+++++--------------------------..-+++++++++++++--
|
|
||||||
++++++++++++++-----+--------------++++++++#+ +...-- + . - ----+-+++--++-+++-----------------------.---++++++++++++++--
|
|
||||||
++++++++----++--+++----------+++++++++++-++#++. . . ----+----++---+--++-------------------------+ +++++++++++++---
|
|
||||||
+++++++++---+--------------++++++++++++++++...-. . -----------+---++++------------------------++++++++++++++++---
|
|
||||||
+++++++---+-++-+++-+-----+++++++++++++++++------ .. ---------+-----+++++-+-----------------++++++++++++++++++----
|
|
||||||
+++++++-+++-+----++------+++++++++++++++++++++--- ....----+--+------++++-++------------.+.+++++++++++##+++++++----
|
|
||||||
++++++++++++++++------+++++++++++++++++++++++++--. . ... ++---+--+----++++---++-------.+-.+++++++++++++#######+------
|
|
||||||
++++---++++------+---++++++++++++++++++++++++++---. ...-.--+--+---++--+------+-+--.-.++.+#+++#+++++###++++++#---------
|
|
||||||
-++++.--+++++---+----+++++++++++++++++++++++++++-+-... ......---+++-++----++-----+--.---++####+++++###--++####++-----.----
|
|
||||||
++++-----+----------++++++++++++++++++++++++++++++--.... ..------++++--------------.+- ############+++##+###+---------------
|
|
||||||
+++-+++------------++++++++++++++++++++++++++++++----..... ..----+. -+---------- ---..+###+####+###-#####++-+--.---------------
|
|
||||||
+++++++++--.------+++++++++++++++++++++++++++++. -----.... . . ..-++-+--+----------- .#++++#++##+--++++---+-----------.-----------
|
|
||||||
-----+-+++--------++++++++++++++++++++++++++++++---.--.... . .. ---++-.+------------ .+-++##--#+--++--.-----------------------------
|
|
||||||
-------+++--------++++#++++++++++++++++++++++-+-------..-. .---+--+++---------. +#-#+#+--+------.---.---.----- ----------------..
|
|
||||||
-------++++++-+------.+++++++++++++++++-++++++++-- ----..... ..----+--+.++-------- +--#++-------------------------....-.---------..--
|
|
||||||
------++++++++++++---+--##++++++++++++++++++++---------......-.--+++-.-++---.---. -##++----------------------- ---.....---------......
|
|
||||||
-++---++++++#+++++##++...+#++++++++ +++++++++---------...-.---.++++...--.-+-----..+#+-----------.---------------.... ......-.........
|
|
||||||
-------+++-+++#+# ++#+....+##+#++++#+++++++++----++++---...----++++-..+.--++---.+#+-------------------------..... . ....... . ...
|
|
||||||
++----+++++++-+---###+-+..-##+##+##+++++++++-.--------.--------+-+--------++---.#-----------..----------...... .. . ..
|
|
||||||
++++-+++++++++++++++##++-..##++++##+++++++++-+----..----+---+--++---------++-------------------------...-. . . ....
|
|
||||||
++++++++++++++++++++++##-..+++++++ ++++++ +- - --------------+-.---------------- ----------.----------.... . .
|
|
||||||
-+++++++-+++++++++++++#+--++.++-----------------------. .--- --------------------------------------...--..... ...... .
|
|
||||||
-++++--++++++++++++++++-++ -++.-------.------------ ------.---------.....--------------------- .--- ....... ......
|
|
||||||
----------++++--------+++.----------..--------.------------ ...-.-.-.----------------------..---.--. .. ........
|
|
||||||
-----.-...-------------------------------.------ -----..----...-.........-----------------.. .-.... ....... ... .
|
|
||||||
........ .--------------------------------------- --... ---......----.. . - ----------.--...-... ......... ..
|
|
||||||
... ..---------------.--.-------------...-.---------.. ....-...... --- --- ---.-----...... .
|
|
||||||
...... .-----.-.----------.---------------..--------.--...... . ..----------------.-.. ..
|
|
||||||
. ...--------------------------...----.--------.-..---.-. .---------------------. . `
|
|
||||||
|
|
||||||
const ChevronDown = () => (
|
|
||||||
<svg className="acc-chev" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
||||||
<polyline points="6 9 12 15 18 9"/>
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const [openAcc, setOpenAcc] = useState<Record<string, boolean>>({
|
|
||||||
'exp-0': true,
|
|
||||||
'edu-0': true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const toggle = (id: string) => {
|
|
||||||
setOpenAcc(prev => ({ ...prev, [id]: !prev[id] }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const [cmd, setCmd] = useState('')
|
|
||||||
const [eggView, setEggView] = useState<'main' | 'moria' | 'balrog'>('main')
|
|
||||||
const [eggBusy, setEggBusy] = useState(false)
|
|
||||||
|
|
||||||
const triggerEgg = (view: 'moria' | 'balrog') => {
|
|
||||||
if (eggBusy) return
|
|
||||||
setEggBusy(true)
|
|
||||||
setEggView(view)
|
|
||||||
setTimeout(() => {
|
|
||||||
setEggView('main')
|
|
||||||
setCmd('')
|
|
||||||
setEggBusy(false)
|
|
||||||
}, 4200)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTermKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
if (e.key !== 'Enter' || eggBusy) return
|
|
||||||
const value = cmd.trim().toLowerCase()
|
|
||||||
if (value === 'mellon') {
|
|
||||||
triggerEgg('moria')
|
|
||||||
} else if (value === 'you shall not pass') {
|
|
||||||
triggerEgg('balrog')
|
|
||||||
} else {
|
|
||||||
setCmd('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="col-rule-l"></div>
|
|
||||||
<div className="col-rule-r"></div>
|
|
||||||
<div className="col-rule-c1"></div>
|
|
||||||
<div className="col-rule-c2"></div>
|
|
||||||
|
|
||||||
{/* NAV */}
|
|
||||||
<nav>
|
|
||||||
<a href="#" className="nav-logo-wrap">
|
|
||||||
<svg className="nav-logosvg" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="22" height="22" rx="4" fill="#1C2033"/>
|
|
||||||
<path d="M5 8L11 5.5L17 8V12.8C17 16.5 11 20 11 20C11 20 5 16.5 5 12.8V8Z" stroke="#E8E5DF" strokeWidth="1.3"/>
|
|
||||||
<path d="M9 11.8L10.8 13.5L14 9.5" stroke="#E8E5DF" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
<span className="nav-name">Pietro<sup>DEV</sup></span>
|
|
||||||
</a>
|
|
||||||
<div className="nav-links">
|
|
||||||
<a href="#skills">Skills</a>
|
|
||||||
<a href="#homelab">Homelab</a>
|
|
||||||
<a href="#desktop">Desktop</a>
|
|
||||||
<a href="#projects">Projects</a>
|
|
||||||
<a href="#github">GitHub</a>
|
|
||||||
<a href="#experience">Experience</a>
|
|
||||||
<a href="#contact">Contact</a>
|
|
||||||
</div>
|
|
||||||
<div className="nav-socials">
|
|
||||||
<a href="https://github.com/Quinta0" target="_blank" rel="noopener" title="GitHub">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12c0 4.42 2.87 8.17 6.84 9.49.5.09.68-.22.68-.48v-1.69C6.72 19.9 6.14 18 6.14 18c-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.52 2.34 1.08 2.91.83.09-.65.35-1.08.63-1.33-2.22-.25-4.56-1.11-4.56-4.95 0-1.09.39-1.98 1.03-2.68-.1-.26-.45-1.27.1-2.64 0 0 .84-.27 2.75 1.02A9.56 9.56 0 0 1 12 7.8c.85.004 1.71.115 2.51.337 1.91-1.29 2.75-1.02 2.75-1.02.55 1.37.2 2.38.1 2.64.64.7 1.03 1.59 1.03 2.68 0 3.85-2.34 4.7-4.57 4.95.36.31.68.92.68 1.85v2.74c0 .27.18.58.69.48A10.01 10.01 0 0 0 22 12c0-5.52-4.48-10-10-10Z"/></svg>
|
|
||||||
</a>
|
|
||||||
<a href="https://www.linkedin.com/in/pietro-quintavalle-996b96267/" target="_blank" rel="noopener" title="LinkedIn">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14m-.5 15.5v-5.3a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.32 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1 1.4 1.4v4.93h2.79M6.88 8.56a1.68 1.68 0 0 0 1.68-1.68c0-.93-.75-1.69-1.68-1.69a1.69 1.69 0 0 0-1.69 1.69c0 .93.76 1.68 1.69 1.68m1.39 9.94v-8.37H5.5v8.37h2.77Z"/></svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* ANNOUNCE STRIP */}
|
|
||||||
<div className="strip">
|
|
||||||
<span className="strip-pill">Open to work</span>
|
|
||||||
<a href="#contact">Available for Junior SysAdmin & IT consulting & Junior Quant <span className="strip-arr">→</span></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HERO */}
|
|
||||||
<div className="hero-row">
|
|
||||||
<div className="hero-gutter"></div>
|
|
||||||
<div className="hero-main">
|
|
||||||
<div className="status-row"><span className="green-dot"></span>Lugano · Switzerland · USI Economics student</div>
|
|
||||||
<div className="hero-name">Pietro<br/>Quintavalle</div>
|
|
||||||
<div className="hero-role">Systems Engineer · Developer · Homelabber</div>
|
|
||||||
<p className="hero-thesis">Economics models incentives under constraints; infrastructure models constraints under load I like sitting where those two disciplines overlap.</p>
|
|
||||||
<p className="hero-lead">5+ years building production-grade infrastructure from the ground up. I design resilient systems and write the software that runs on top of them then secure and monitor all of it myself.</p>
|
|
||||||
|
|
||||||
<div className="btns">
|
|
||||||
<a href="#contact" className="btn btn-d">Get in touch →</a>
|
|
||||||
<a href="#homelab" className="btn btn-o">Explore homelab</a>
|
|
||||||
<a href="#projects" className="btn btn-o">View projects</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hero-stats">
|
|
||||||
<div className="stat"><span className="stat-ico">⚡</span><div><div className="stat-val">90%+ uptime</div><div className="stat-lbl">Production servers</div></div></div>
|
|
||||||
<div className="stat"><span className="stat-ico">💻</span><div><div className="stat-val">4 servers</div><div className="stat-lbl">Under management</div></div></div>
|
|
||||||
<div className="stat"><span className="stat-ico">📅</span><div><div className="stat-val">5+ years</div><div className="stat-lbl">SysAdmin experience</div></div></div>
|
|
||||||
<div className="stat"><span className="stat-ico">🎓</span><div><div className="stat-val">BSc Economics</div><div className="stat-lbl">USI · Ongoing</div></div></div>
|
|
||||||
<div className="stat"><span className="stat-ico">🌐</span><div><div className="stat-val">IT · EN · DE · ES · NO</div><div className="stat-lbl">5 languages</div></div></div>
|
|
||||||
</div>
|
|
||||||
<div className="hero-gutter-r"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ABOUT + TERMINAL */}
|
|
||||||
<div className="about-split">
|
|
||||||
<div className="about-g"></div>
|
|
||||||
<div className="about-left">
|
|
||||||
<div className="terminal">
|
|
||||||
<div className="t-bar"><span className="td r"></span><span className="td y"></span><span className="td g"></span><span className="t-title">quinta@proxmox:~</span></div>
|
|
||||||
<div className="t-stage">
|
|
||||||
<div className={`t-pane t-body${eggView !== 'main' ? ' t-hidden' : ''}`}>
|
|
||||||
<div><span className="t-p">➤</span> <span className="t-c">~</span> neofetch</div>
|
|
||||||
<div> </div>
|
|
||||||
<div><span className="t-k">OS </span><span className="t-v">Proxmox VE 8 / UNRAID 7.3.1 / TrueNAS</span></div>
|
|
||||||
<div><span className="t-k">Virt </span><span className="t-v">LXC · Docker · KVM</span></div>
|
|
||||||
<div><span className="t-k">DNS </span><span className="t-v">Cloudflare · AAAA · AdGuard</span></div>
|
|
||||||
<div><span className="t-k">Tunnels </span><span className="t-v">Cloudflare Tunnel · Newt</span></div>
|
|
||||||
<div><span className="t-k">Proxy </span><span className="t-v">Pangolin + Traefik + Auto-TLS</span></div>
|
|
||||||
<div><span className="t-k">Security </span><span className="t-v">Fail2Ban · Crowdsec · Authelia</span></div>
|
|
||||||
<div><span className="t-k">Storage </span><span className="t-v">UNRAID parity · TrueNAS ZFS RAID 10</span></div>
|
|
||||||
<div><span className="t-k">Media </span><span className="t-v">Jellyfin · Navidrome · *arr</span></div>
|
|
||||||
<div><span className="t-k">Music </span><span className="t-v">Listenbrainz · Symfonium</span></div>
|
|
||||||
<div><span className="t-k">Monitor </span><span className="t-v">Grafana · Prometheus · Kuma</span></div>
|
|
||||||
<div><span className="t-k">Code </span><span className="t-v">Python · TypeScript · SQL</span></div>
|
|
||||||
<div><span className="t-k">Status </span><span className="t-v" style={{color:'#5FCE8A'}}>Open for work ●</span></div>
|
|
||||||
<div> </div>
|
|
||||||
<div className="t-cmd-row">
|
|
||||||
<span className="t-p">➤</span> <span className="t-c">~</span>
|
|
||||||
<input
|
|
||||||
className="t-input"
|
|
||||||
type="text"
|
|
||||||
value={cmd}
|
|
||||||
onChange={e => setCmd(e.target.value)}
|
|
||||||
onKeyDown={handleTermKey}
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
aria-label="terminal command input"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="t-hint">try typing something...</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`t-pane t-egg-pane${eggView === 'moria' ? ' t-egg-show' : ''}`}>
|
|
||||||
<div className="t-ascii-scroll">
|
|
||||||
<pre className="t-ascii t-ascii-moria">{DURIN_ART}</pre>
|
|
||||||
</div>
|
|
||||||
<div className="t-egg-cap t-egg-cap-moria">the doors of durin, lord of moria speak, friend, and enter</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`t-pane t-egg-pane${eggView === 'balrog' ? ' t-egg-show' : ''}`}>
|
|
||||||
<div className="t-ascii-scroll">
|
|
||||||
<pre className="t-ascii t-ascii-balrog">{BALROG_ART}</pre>
|
|
||||||
</div>
|
|
||||||
<div className="t-egg-cap t-egg-cap-balrog">a demon of the ancient world YOU SHALL NOT PASS</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="about-right">
|
|
||||||
<div className="slbl">About me</div>
|
|
||||||
<p>Hi friends call me <strong>Quinta</strong>. I’m an Economics student at USI Lugano and a self-taught systems engineer who’s been running homelab infrastructure since 2020.</p>
|
|
||||||
<p>My setup spans everything from <strong>bare-metal Proxmox clusters</strong> to <strong>DNS and domain management via Cloudflare</strong>, <strong>Pangolin/Traefik reverse proxying</strong>, and a full media+music automation pipeline.</p>
|
|
||||||
<p>On the academic side I’m deepening my understanding of <strong>microeconomics, macroeconomics, game theory, and quantitative finance</strong> and finding more overlap with systems thinking than you’d expect.</p>
|
|
||||||
<p style={{fontSize:'12.5px',color:'var(--muted)',fontFamily:'\'DM Mono\',monospace',marginTop:'4px'}}>Daily driver Ryzen 7 9800X3D · RTX 5080 · hand-built custom keyboard</p>
|
|
||||||
</div>
|
|
||||||
<div className="about-gr"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SKILLS */}
|
|
||||||
<div className="sec" id="skills">
|
|
||||||
<div className="sec-g"></div>
|
|
||||||
<div className="sec-body">
|
|
||||||
<div className="slbl">Technical Arsenal</div>
|
|
||||||
<div className="sk4">
|
|
||||||
<div className="sk"><div className="sk-ico">🖧</div><div className="sk-t">System & Infra</div><div className="tags"><span className="tag">Proxmox VE</span><span className="tag">UNRAID</span><span className="tag">TrueNAS</span><span className="tag">Docker</span><span className="tag">LXC</span><span className="tag">KVM</span><span className="tag">Linux</span><span className="tag">Win Server</span><span className="tag">AD</span></div></div>
|
|
||||||
<div className="sk"><div className="sk-ico">🔒</div><div className="sk-t">Network & Security</div><div className="tags"><span className="tag">VLAN</span><span className="tag">Cloudflare</span><span className="tag">AAAA/DNS</span><span className="tag">CF Tunnels</span><span className="tag">Tailscale</span><span className="tag">Fail2Ban</span><span className="tag">Crowdsec</span><span className="tag">UFW</span><span className="tag">Authelia</span><span className="tag">Traefik</span></div></div>
|
|
||||||
<div className="sk"><div className="sk-ico">⌨</div><div className="sk-t">Development</div><div className="tags"><span className="tag">Python</span><span className="tag">TypeScript</span><span className="tag">React</span><span className="tag">Next.js</span><span className="tag">Django</span><span className="tag">SQL</span><span className="tag">Bash</span><span className="tag">Ansible</span><span className="tag">HTML/CSS</span><span className="tag">Soldering</span></div></div>
|
|
||||||
<div className="sk"><div className="sk-ico">📊</div><div className="sk-t">Data & Monitoring</div><div className="tags"><span className="tag">Grafana</span><span className="tag">Prometheus</span><span className="tag">Uptime Kuma</span><span className="tag">ZFS</span><span className="tag">rsync</span><span className="tag">RAID</span><span className="tag">cAdvisor</span></div></div>
|
|
||||||
</div>
|
|
||||||
<div style={{marginTop:'32px'}}>
|
|
||||||
<div className="slbl">Economics & Quant</div>
|
|
||||||
<div className="sk4">
|
|
||||||
<div className="sk"><div className="sk-ico">📈</div><div className="sk-t">Microeconomics</div><div className="tags"><span className="tag">Consumer theory</span><span className="tag">Market structures</span><span className="tag">Production & costs</span><span className="tag">Price theory</span><span className="tag">Welfare econ.</span></div></div>
|
|
||||||
<div className="sk"><div className="sk-ico">🌎</div><div className="sk-t">Macroeconomics</div><div className="tags"><span className="tag">GDP & growth</span><span className="tag">Monetary policy</span><span className="tag">Fiscal policy</span><span className="tag">IS-LM model</span><span className="tag">Open economy</span></div></div>
|
|
||||||
<div className="sk"><div className="sk-ico">⚅</div><div className="sk-t">Game Theory</div><div className="tags"><span className="tag">Nash equilibrium</span><span className="tag">Dominant strategies</span><span className="tag">Extensive form</span><span className="tag">Mechanism design</span><span className="tag">Auctions</span></div></div>
|
|
||||||
<div className="sk"><div className="sk-ico">💹</div><div className="sk-t">Quantitative Finance</div><div className="tags"><span className="tag">Time value of money</span><span className="tag">Probability</span><span className="tag">Statistics</span><span className="tag">Regression</span><span className="tag">Financial modelling</span></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{marginTop:'28px'}}>
|
|
||||||
<div className="slbl">Certifications</div>
|
|
||||||
<div className="certs">
|
|
||||||
<div className="cert">🎓 Google Data Analytics Professional Certificate</div>
|
|
||||||
<div className="cert">🎓 Google IT Support Professional Certificate</div>
|
|
||||||
<div className="cert">🎓 Microsoft Foundations of IT Systems, Networking & Data Protection</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sec-gr"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* HOMELAB */}
|
|
||||||
<div className="sec" id="homelab">
|
|
||||||
<div className="sec-g"></div>
|
|
||||||
<div className="sec-body">
|
|
||||||
<div className="slbl">Homelab Infrastructure</div>
|
|
||||||
<p style={{fontSize:'14px',color:'var(--mid)',marginBottom:'32px',maxWidth:'680px',lineHeight:'1.65',fontWeight:'300'}}>Every service containerised, every entry-point proxied, every failure logged. Domain management via Cloudflare with AAAA records and tunnels. Built to enterprise patterns at personal scale.</p>
|
|
||||||
|
|
||||||
<div className="slbl" style={{marginBottom:'14px'}}>Hardware</div>
|
|
||||||
<table className="hw-table" style={{marginBottom:'32px',border:'1px solid var(--line3)',borderRadius:'5px',overflow:'hidden'}}>
|
|
||||||
<thead><tr><th>Component</th><th>UNRAID Server</th><th>Proxmox Node</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>CPU</td><td>Intel Core Ultra 5 225</td><td>Intel Core i7-8700</td></tr>
|
|
||||||
<tr><td>RAM</td><td>48GB DDR5 5600MHz</td><td>32GB DDR4</td></tr>
|
|
||||||
<tr><td>Storage</td><td>4× HDD parity array + NVMe cache</td><td>SSD (OS) + SATA pool</td></tr>
|
|
||||||
<tr><td>Network</td><td>1Gbps LAN · VLANs</td><td>1Gbps LAN · VLANs</td></tr>
|
|
||||||
<tr><td>OS</td><td>UNRAID 7.3.1</td><td>Proxmox VE 8</td></tr>
|
|
||||||
<tr><td>Access</td><td>Pangolin + Traefik</td><td>Tailscale + SSH</td></tr>
|
|
||||||
<tr><td>Focus</td><td>Idle power efficiency · perf/watt</td><td>Virtualisation · LXC</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div className="slbl" style={{marginBottom:'14px'}}>Services & Stack</div>
|
|
||||||
<div className="hl3">
|
|
||||||
<div className="hl">
|
|
||||||
<div className="hl-hd"><span className="hl-ico">💾</span><span className="hl-t">UNRAID NAS</span></div>
|
|
||||||
<div className="hl-d">Parity-protected array hosting 9-user network shares, Docker Appdata, automated backups. Acts as the central file hub for all services via SMB/NFS.</div>
|
|
||||||
<div className="specs"><span className="spec">UNRAID 7.3.1</span><span className="spec">Parity array</span><span className="spec">NVMe cache</span><span className="spec">9 users</span><span className="spec">rsync</span><span className="spec">SMB/NFS</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="hl">
|
|
||||||
<div className="hl-hd"><span className="hl-ico">🌎</span><span className="hl-t">DNS & Cloudflare</span></div>
|
|
||||||
<div className="hl-d">Domain managed through Cloudflare with AAAA records for IPv6, Cloudflare Tunnels for zero-open-port external access, and AdGuard Home for internal DNS resolution and ad-blocking.</div>
|
|
||||||
<div className="specs"><span className="spec">Cloudflare</span><span className="spec">AAAA records</span><span className="spec">CF Tunnels</span><span className="spec">AdGuard Home</span><span className="spec">Split DNS</span><span className="spec">DNSSEC</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="hl">
|
|
||||||
<div className="hl-hd"><span className="hl-ico">🔀</span><span className="hl-t">Pangolin + Traefik</span></div>
|
|
||||||
<div className="hl-d">Pangolin (Traefik-based) handles all inbound HTTPS with automatic TLS, subdomain routing, and middleware. Newt tunnel enables access without exposing raw ports. Authelia adds SSO/2FA.</div>
|
|
||||||
<div className="specs"><span className="spec">Pangolin</span><span className="spec">Traefik</span><span className="spec">Auto-TLS</span><span className="spec">Newt tunnel</span><span className="spec">Authelia</span><span className="spec">Middleware</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="hl">
|
|
||||||
<div className="hl-hd"><span className="hl-ico">🛡</span><span className="hl-t">Fail2Ban + Security</span></div>
|
|
||||||
<div className="hl-d">Fail2Ban monitors SSH and auth logs, auto-banning brute-force IPs. Crowdsec adds community threat intelligence with real-time blocklist sync. UFW provides host-level firewall rules on each node.</div>
|
|
||||||
<div className="specs"><span className="spec">Fail2Ban</span><span className="spec">Crowdsec</span><span className="spec">UFW</span><span className="spec">iptables</span><span className="spec">SSH hardening</span><span className="spec">Authelia</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="hl">
|
|
||||||
<div className="hl-hd"><span className="hl-ico">🎬</span><span className="hl-t">Jellyfin + Media</span></div>
|
|
||||||
<div className="hl-d">Jellyfin handles hardware-transcoded streaming with per-user OAuth authentication and Authelia SSO. Sonarr/Radarr auto-manage TV and film libraries; Bazarr handles subtitles; Prowlarr indexes trackers.</div>
|
|
||||||
<div className="specs"><span className="spec">Jellyfin</span><span className="spec">HW transcode</span><span className="spec">OAuth</span><span className="spec">Sonarr</span><span className="spec">Radarr</span><span className="spec">Bazarr</span><span className="spec">Prowlarr</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="hl">
|
|
||||||
<div className="hl-hd"><span className="hl-ico">🎵</span><span className="hl-t">Music Pipeline</span></div>
|
|
||||||
<div className="hl-d">Lidarr monitors for releases → Prowlarr indexes → Beets normalises tags → Navidrome streams via Subsonic API. Scrobbling to Listenbrainz. Mobile playback via Symfonium and Explo apps.</div>
|
|
||||||
<div className="specs"><span className="spec">Navidrome</span><span className="spec">Lidarr</span><span className="spec">Prowlarr</span><span className="spec">Beets</span><span className="spec">Listenbrainz</span><span className="spec">Symfonium</span><span className="spec">Explo</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="note">
|
|
||||||
<span className="note-ico">🔗</span>
|
|
||||||
<div>
|
|
||||||
<div className="note-t">Zero open ports via Tailscale + Cloudflare Tunnels</div>
|
|
||||||
<div className="note-d">All services reachable remotely through a combination of Tailscale mesh VPN (for trusted devices) and Cloudflare Tunnels (for public-facing services). No port-forwarding. MagicDNS handles internal name resolution. Pangolin’s Newt agent tunnels external traffic into the private LAN.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sec-gr"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DESKTOP */}
|
|
||||||
<div className="sec" id="desktop">
|
|
||||||
<div className="sec-g"></div>
|
|
||||||
<div className="sec-body">
|
|
||||||
<div className="slbl">Personal Workstation</div>
|
|
||||||
<p style={{fontSize:'14px',color:'var(--mid)',marginBottom:'32px',maxWidth:'680px',lineHeight:'1.65',fontWeight:'300'}}>Daily driver and dev machine. Built around AMD’s 3D V-Cache architecture for a blend of high single-threaded performance and serious GPU compute doubles as a gaming rig, 3D modelling workstation, and 3D printing controller.</p>
|
|
||||||
|
|
||||||
<table className="hw-table" style={{marginBottom:'32px',border:'1px solid var(--line3)',borderRadius:'5px',overflow:'hidden'}}>
|
|
||||||
<thead><tr><th>Component</th><th>Spec</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr><td>CPU</td><td>AMD Ryzen 7 9800X3D (8c/16t · 3D V-Cache)</td></tr>
|
|
||||||
<tr><td>GPU</td><td>Zotac RTX 5080 Solid OC</td></tr>
|
|
||||||
<tr><td>RAM</td><td>32GB DDR5 6000MHz</td></tr>
|
|
||||||
<tr><td>Motherboard</td><td>Gigabyte X870E AORUS PRO X3D ICE</td></tr>
|
|
||||||
<tr><td>OS</td><td>CachyOS</td></tr>
|
|
||||||
<tr><td>Role</td><td>Dev · Gaming · 3D Modelling · 3D Printing · Daily Driver</td></tr>
|
|
||||||
<tr><td>Keyboard</td><td>Weikav Stars 75 · Huano Sakura switches (hand-lubed, custom assembly)</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div className="hl3" style={{marginBottom:'0',gridTemplateColumns:'repeat(2,1fr)'}}>
|
|
||||||
<div className="hl">
|
|
||||||
<div className="hl-hd"><span className="hl-ico">⚡</span><span className="hl-t">AMD Ryzen 7 9800X3D</span></div>
|
|
||||||
<div className="hl-d">Zen 5 architecture with 96MB 3D V-Cache. Exceptional single-thread and gaming performance, with low-latency cache access ideal for simulation and data-heavy workloads.</div>
|
|
||||||
<div className="specs"><span className="spec">Zen 5</span><span className="spec">8c / 16t</span><span className="spec">96MB L3 3D V-Cache</span><span className="spec">5.0GHz boost</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="hl">
|
|
||||||
<div className="hl-hd"><span className="hl-ico">🎮</span><span className="hl-t">Zotac RTX 5080 Solid OC</span></div>
|
|
||||||
<div className="hl-d">Blackwell architecture. 16GB GDDR7, hardware ray tracing, DLSS 4 with Multi Frame Generation. Used for gaming, 3D rendering, and GPU-accelerated dev and compute tasks.</div>
|
|
||||||
<div className="specs"><span className="spec">Blackwell</span><span className="spec">16GB GDDR7</span><span className="spec">DLSS 4</span><span className="spec">MFG</span><span className="spec">CUDA</span><span className="spec">Ray Tracing</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="hl">
|
|
||||||
<div className="hl-hd"><span className="hl-ico">🔌</span><span className="hl-t">X870E AORUS PRO X3D ICE</span></div>
|
|
||||||
<div className="hl-d">Gigabyte X870E flagship board with PCIe 5.0, USB4, and optimised X3D power delivery. Supports DDR5 6000MHz XMP out-of-the-box. White aesthetic matches the build’s ICE theme.</div>
|
|
||||||
<div className="specs"><span className="spec">X870E</span><span className="spec">PCIe 5.0</span><span className="spec">USB4</span><span className="spec">DDR5 6000</span><span className="spec">WiFi 7</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="hl">
|
|
||||||
<div className="hl-hd"><span className="hl-ico">⌨</span><span className="hl-t">Weikav Stars 75</span></div>
|
|
||||||
<div className="hl-d">Fully custom 75% aluminium keyboard hand-lubed Huano Sakura tactile switches, gasket mount, per-key RGB, 1000Hz polling. Full assembly and switch tuning done from scratch.</div>
|
|
||||||
<div className="specs"><span className="spec">75% layout</span><span className="spec">Huano Sakura</span><span className="spec">Tactile</span><span className="spec">Hand-lubed</span><span className="spec">Gasket mount</span><span className="spec">Aluminium</span><span className="spec">1000Hz</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sec-gr"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PROJECTS */}
|
|
||||||
<div className="sec" id="projects">
|
|
||||||
<div className="sec-g"></div>
|
|
||||||
<div className="sec-body">
|
|
||||||
<div className="slbl">Personal & Client Projects</div>
|
|
||||||
<div className="pr3">
|
|
||||||
<div className="pr">
|
|
||||||
<span className="badge b-live">● Live</span>
|
|
||||||
<div className="pr-type">Professional Client</div>
|
|
||||||
<div className="pr-t">Enterprise Proxmox Cluster</div>
|
|
||||||
<div className="pr-d">Full single-node virtualisation with VLAN segmentation, Pangolin/Traefik reverse proxy, Cloudflare Tunnels, and centralised Grafana/Prometheus monitoring. 90%+ uptime maintained.</div>
|
|
||||||
<div className="pr-tags"><span className="pr-tag">Proxmox</span><span className="pr-tag">Docker</span><span className="pr-tag">LXC</span><span className="pr-tag">Traefik</span><span className="pr-tag">Tailscale</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="pr">
|
|
||||||
<span className="badge b-live">● Live</span>
|
|
||||||
<div className="pr-type">Professional Client</div>
|
|
||||||
<div className="pr-t">9-User RAID 10 NAS</div>
|
|
||||||
<div className="pr-d">TrueNAS on RAID 10 serving as a self-hosted cloud for 9 users. Per-user ACLs, automated rsync offsite backups, ZFS snapshots for instant point-in-time recovery.</div>
|
|
||||||
<div className="pr-tags"><span className="pr-tag">TrueNAS</span><span className="pr-tag">RAID 10</span><span className="pr-tag">ZFS</span><span className="pr-tag">rsync</span><span className="pr-tag">ACLs</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="pr">
|
|
||||||
<span className="badge b-oss">↗ Open Source</span>
|
|
||||||
<div className="pr-type">Personal Project</div>
|
|
||||||
<div className="pr-t">Valgo Algorithm Visualizer</div>
|
|
||||||
<div className="pr-d">Step-by-step algorithm visualisation built with React and Next.js. Sorting, graph, and search algorithms with live code annotations and speed controls.</div>
|
|
||||||
<div className="pr-tags"><span className="pr-tag">React</span><span className="pr-tag">Next.js</span><span className="pr-tag">TypeScript</span><span className="pr-tag">Tailwind</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="pr">
|
|
||||||
<span className="badge b-dev">◐ In Dev</span>
|
|
||||||
<div className="pr-type">Personal Project</div>
|
|
||||||
<div className="pr-t">Finance Tracker</div>
|
|
||||||
<div className="pr-d">50/30/20 budgeting tool with Django backend, SQLite, and Chart.js dashboards. Monthly trend analysis, category breakdowns, and exportable reports.</div>
|
|
||||||
<div className="pr-tags"><span className="pr-tag">Django</span><span className="pr-tag">Python</span><span className="pr-tag">SQLite</span><span className="pr-tag">Chart.js</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="pr">
|
|
||||||
<span className="badge b-live">● Live</span>
|
|
||||||
<div className="pr-type">Personal Homelab</div>
|
|
||||||
<div className="pr-t">Self-Hosted Stack (IaC)</div>
|
|
||||||
<div className="pr-d">Entire homelab reproduced from code Ansible playbooks, Docker Compose, and Bash scripts. Idempotent provisioning; full rebuild in under 30 minutes from scratch.</div>
|
|
||||||
<div className="pr-tags"><span className="pr-tag">Ansible</span><span className="pr-tag">Compose</span><span className="pr-tag">Bash</span><span className="pr-tag">IaC</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="pr">
|
|
||||||
<span className="badge b-oss">↗ Open Source</span>
|
|
||||||
<div className="pr-type">Personal Project</div>
|
|
||||||
<div className="pr-t">re-soulcommand</div>
|
|
||||||
<div className="pr-d">Music recommendation downloader integrating ListenBrainz and Last.fm playlists with Navidrome via slskd (Soulseek daemon). Fork rebuilt around a REST API backend stripped of bloat, Docker-native.</div>
|
|
||||||
<div className="pr-tags"><span className="pr-tag">Python</span><span className="pr-tag">Docker</span><span className="pr-tag">ListenBrainz</span><span className="pr-tag">Last.fm</span><span className="pr-tag">slskd</span><span className="pr-tag">Navidrome</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="pr">
|
|
||||||
<span className="badge b-live">● Live</span>
|
|
||||||
<div className="pr-type">Personal Homelab</div>
|
|
||||||
<div className="pr-t">Ainulindale Music Stack</div>
|
|
||||||
<div className="pr-d">Fully self-hosted music discovery and streaming platform. Auto-discovers music weekly from listening habits, downloads via Soulseek P2P through a VPN gateway, streams via Navidrome. No commercial services required.</div>
|
|
||||||
<div className="pr-tags"><span className="pr-tag">Docker</span><span className="pr-tag">Navidrome</span><span className="pr-tag">slskd</span><span className="pr-tag">Gluetun</span><span className="pr-tag">ListenBrainz</span><span className="pr-tag">ProtonVPN</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="pr">
|
|
||||||
<span className="badge b-live">● Live</span>
|
|
||||||
<div className="pr-type">Personal Homelab</div>
|
|
||||||
<div className="pr-t">UNRAID Home Server</div>
|
|
||||||
<div className="pr-d">Intel Core Ultra 5 225 build running UNRAID 7.3.1 as primary NAS, Docker host, and media stack. Parity-protected array with NVMe cache, 9-user SMB/NFS shares, and all homelab services containerised.</div>
|
|
||||||
<div className="pr-tags"><span className="pr-tag">UNRAID 7.3.1</span><span className="pr-tag">Docker</span><span className="pr-tag">Parity Array</span><span className="pr-tag">48GB DDR5</span><span className="pr-tag">SMB/NFS</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="pr">
|
|
||||||
<span className="badge b-live">● Live</span>
|
|
||||||
<div className="pr-type">Personal Homelab</div>
|
|
||||||
<div className="pr-t">Proxmox Virtualisation Node</div>
|
|
||||||
<div className="pr-d">Intel Core i7-8700 node running Proxmox VE 8 for LXC containers and KVM VMs. VLAN-segmented network, Tailscale + SSH access, acts as the compute backbone for all hosted services.</div>
|
|
||||||
<div className="pr-tags"><span className="pr-tag">Proxmox VE 8</span><span className="pr-tag">LXC</span><span className="pr-tag">KVM</span><span className="pr-tag">Tailscale</span><span className="pr-tag">VLAN</span><span className="pr-tag">32GB DDR4</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="pr">
|
|
||||||
<span className="badge b-oss">↗ Open Source · ★ 26</span>
|
|
||||||
<div className="pr-type">Personal Project</div>
|
|
||||||
<div className="pr-t">ObsidianSetup</div>
|
|
||||||
<div className="pr-d">Cross-platform utility script that makes installing Obsidian and its plugins consistent across devices PowerShell and Bash installers driven by a single plugins.json manifest.</div>
|
|
||||||
<div className="pr-tags"><span className="pr-tag">PowerShell</span><span className="pr-tag">Bash</span><span className="pr-tag">Obsidian</span><span className="pr-tag">Automation</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="pr">
|
|
||||||
<span className="badge b-oss">↗ Open Source</span>
|
|
||||||
<div className="pr-type">Academic Project</div>
|
|
||||||
<div className="pr-t">Boston Housing Price Prediction</div>
|
|
||||||
<div className="pr-d">Probability & Statistics coursework at USI built and evaluated regression models on the classic Boston housing dataset to predict median home values from neighbourhood features.</div>
|
|
||||||
<div className="pr-tags"><span className="pr-tag">Python</span><span className="pr-tag">scikit-learn</span><span className="pr-tag">Regression</span><span className="pr-tag">Jupyter</span><span className="pr-tag">Statistics</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p style={{marginTop:'16px',fontSize:'12px',color:'var(--muted)',fontFamily:'\'DM Mono\',monospace'}}>
|
|
||||||
More projects at <a href="https://github.com/Quinta0" target="_blank" rel="noopener" style={{color:'var(--mid)',textDecoration:'none',borderBottom:'1px solid var(--line3)'}}>github.com/Quinta0 ↗</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="sec-gr"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* GITHUB */}
|
|
||||||
<div className="sec" id="github">
|
|
||||||
<div className="sec-g"></div>
|
|
||||||
<div className="sec-body">
|
|
||||||
<div className="slbl">GitHub Contributions & Issues</div>
|
|
||||||
<div className="gh-list">
|
|
||||||
<div className="gh-item"><div style={{flex:1}}><div className="gh-n">Quinta0/re-soulcommand</div><div className="gh-d">Music recommendation downloader ListenBrainz & Last.fm playlists into Navidrome via slskd. Author & maintainer.</div></div><div className="gh-r"><span className="gh-lang">Python</span><a href="https://github.com/Quinta0/re-soulcommand" target="_blank" rel="noopener" className="gh-lnk">↗ repo</a></div></div>
|
|
||||||
<div className="gh-item"><div style={{flex:1}}><div className="gh-n">Quinta0/Ainulindale</div><div className="gh-d">Self-hosted music discovery & streaming stack auto-discovery, Soulseek P2P download via VPN, Navidrome streaming. Author & maintainer.</div></div><div className="gh-r"><span className="gh-lang">Shell</span><a href="https://github.com/Quinta0/Ainulindale" target="_blank" rel="noopener" className="gh-lnk">↗ repo</a></div></div>
|
|
||||||
<div className="gh-item"><div style={{flex:1}}><div className="gh-n">Quinta0/homelab</div><div className="gh-d">IaC scripts, Ansible playbooks, Docker Compose stacks for full homelab provisioning.</div></div><div className="gh-r"><span className="gh-lang">Shell</span><a href="https://github.com/Quinta0" target="_blank" rel="noopener" className="gh-lnk">↗ repo</a></div></div>
|
|
||||||
<div className="gh-item"><div style={{flex:1}}><div className="gh-n">Quinta0/ObsidianSetup</div><div className="gh-d">Cross-platform Obsidian + plugin installer, driven by a shared plugins.json manifest. Author & maintainer.</div></div><div className="gh-r"><span className="gh-stars">★ 26</span><span className="gh-lang">PowerShell</span><a href="https://github.com/Quinta0/ObsidianSetup" target="_blank" rel="noopener" className="gh-lnk">↗ repo</a></div></div>
|
|
||||||
<div className="gh-item"><div style={{flex:1}}><div className="gh-n">Quinta0/BostonHousing</div><div className="gh-d">Probability & Statistics coursework at USI regression models predicting Boston-area housing prices.</div></div><div className="gh-r"><span className="gh-lang">Jupyter</span><a href="https://github.com/Quinta0/BostonHousing" target="_blank" rel="noopener" className="gh-lnk">↗ repo</a></div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sec-gr"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* EXPERIENCE + EDUCATION */}
|
|
||||||
<div className="sec" id="experience">
|
|
||||||
<div className="sec-g"></div>
|
|
||||||
<div className="sec-body">
|
|
||||||
<div className="slbl">Experience</div>
|
|
||||||
<div className="acc">
|
|
||||||
<div className={`acc-item${openAcc['exp-0'] ? ' open' : ''}`}>
|
|
||||||
<button className="acc-btn" onClick={() => toggle('exp-0')}>
|
|
||||||
<div className="acc-l"><div className="acc-t">Systems Engineering Consultant</div><div className="acc-s">Freelance · Ticino, Switzerland</div></div>
|
|
||||||
<div className="acc-r2"><span className="acc-per">Jan 2020 Present</span><ChevronDown/></div>
|
|
||||||
</button>
|
|
||||||
<div className="acc-body">
|
|
||||||
<p>End-to-end IT infrastructure for private and business clients in Ticino.</p>
|
|
||||||
<ul>
|
|
||||||
<li>Delivered and maintained 4 production servers at 90%+ uptime using containerisation, IaC, and automated monitoring all without managed hosting.</li>
|
|
||||||
<li>Eliminated public exposure across all client servers by routing every external entry-point through Cloudflare Tunnels and Pangolin/Traefik zero open ports, DNSSEC enforced, split-DNS via AdGuard Home.</li>
|
|
||||||
<li>Pangolin + Traefik reverse proxy with Authelia SSO, automatic TLS, and Newt tunnelling.</li>
|
|
||||||
<li>Fail2Ban + Crowdsec intrusion detection; UFW + iptables firewall rules across all nodes.</li>
|
|
||||||
<li>Reduced storage recovery time to under 30 minutes with ZFS snapshots on the TrueNAS RAID 10 array and automated rsync offsite backups across all storage nodes.</li>
|
|
||||||
<li>Music pipeline: Lidarr → Prowlarr → Beets → Navidrome + Listenbrainz scrobbling + Symfonium/Explo mobile.</li>
|
|
||||||
<li>Windows Server: Active Directory, DNS, DHCP, Group Policy, Disaster Recovery planning.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{marginTop:'44px'}}><div className="slbl">Education</div></div>
|
|
||||||
<div className="acc">
|
|
||||||
<div className={`acc-item${openAcc['edu-0'] ? ' open' : ''}`}>
|
|
||||||
<button className="acc-btn" onClick={() => toggle('edu-0')}>
|
|
||||||
<div className="acc-l"><div className="acc-t">BSc in Economics</div><div className="acc-s">USI Università della Svizzera italiana · Lugano</div></div>
|
|
||||||
<div className="acc-r2"><span className="acc-per">Sep 2024 Present</span><ChevronDown/></div>
|
|
||||||
</button>
|
|
||||||
<div className="acc-body">
|
|
||||||
<p>Ongoing bachelor’s focusing on rigorous theory and quantitative methods.</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong style={{color:'var(--navy)'}}>Microeconomics</strong> consumer and producer theory, market structures (perfect competition, oligopoly, monopoly), welfare economics, externalities.</li>
|
|
||||||
<li><strong style={{color:'var(--navy)'}}>Macroeconomics</strong> GDP and growth models, IS-LM, monetary and fiscal policy, open economy models, inflation and unemployment dynamics.</li>
|
|
||||||
<li><strong style={{color:'var(--navy)'}}>Game Theory</strong> Nash equilibrium, dominant strategies, extensive and normal form games, mechanism design, auction theory, repeated games.</li>
|
|
||||||
<li><strong style={{color:'var(--navy)'}}>Quantitative Finance & Statistics</strong> probability theory, statistical inference, regression analysis, time series, financial modelling, portfolio theory basics.</li>
|
|
||||||
<li>Accounting, Statistics, and Quantitative Methods for economic analysis.</li>
|
|
||||||
</ul>
|
|
||||||
<div className="econ-tags">
|
|
||||||
<span className="econ-tag">Game Theory</span><span className="econ-tag">Microeconomics</span><span className="econ-tag">Macroeconomics</span><span className="econ-tag">Quantitative Finance</span><span className="econ-tag">Statistics</span><span className="econ-tag">Financial Modelling</span><span className="econ-tag">IS-LM</span><span className="econ-tag">Nash Equilibrium</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={`acc-item${openAcc['edu-1'] ? ' open' : ''}`}>
|
|
||||||
<button className="acc-btn" onClick={() => toggle('edu-1')}>
|
|
||||||
<div className="acc-l"><div className="acc-t">BSc in Informatics <span style={{fontSize:'11.5px',color:'var(--muted)',fontWeight:400,fontFamily:'\'DM Mono\',monospace'}}> 2 yrs completed</span></div><div className="acc-s">USI Università della Svizzera italiana · Lugano</div></div>
|
|
||||||
<div className="acc-r2"><span className="acc-per">Sep 2022 Jun 2024</span><ChevronDown/></div>
|
|
||||||
</button>
|
|
||||||
<div className="acc-body">
|
|
||||||
<p>Two years of rigorous computer science foundations before transitioning to Economics.</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong style={{color:'var(--navy)'}}>Software Engineering</strong> agile methodologies, requirements engineering, UML modelling, design patterns, and testing practices.</li>
|
|
||||||
<li><strong style={{color:'var(--navy)'}}>Data Structures & Algorithms</strong> complexity analysis (Big-O), arrays, linked lists, trees, graphs, and sorting and searching algorithms.</li>
|
|
||||||
<li><strong style={{color:'var(--navy)'}}>Object-Oriented Programming</strong> Java and C++ with inheritance, polymorphism, encapsulation, and SOLID principles.</li>
|
|
||||||
<li><strong style={{color:'var(--navy)'}}>Web Development</strong> HTML, CSS, and JavaScript fundamentals with introductory full-stack concepts.</li>
|
|
||||||
<li>Discrete mathematics, logic, and formal reasoning for computer science.</li>
|
|
||||||
</ul>
|
|
||||||
<div className="econ-tags">
|
|
||||||
<span className="econ-tag">Java</span><span className="econ-tag">C++</span><span className="econ-tag">Algorithms</span><span className="econ-tag">Data Structures</span><span className="econ-tag">OOP</span><span className="econ-tag">Agile</span><span className="econ-tag">Software Engineering</span><span className="econ-tag">Web Dev</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={`acc-item${openAcc['edu-2'] ? ' open' : ''}`}>
|
|
||||||
<button className="acc-btn" onClick={() => toggle('edu-2')}>
|
|
||||||
<div className="acc-l"><div className="acc-t">Maturità Cantonale & AFC Economics</div><div className="acc-s">Scuola cantonale di commercio · Bellinzona</div></div>
|
|
||||||
<div className="acc-r2"><span className="acc-per">Sep 2018 Jun 2022</span><ChevronDown/></div>
|
|
||||||
</button>
|
|
||||||
<div className="acc-body">
|
|
||||||
<p>Swiss dual-track qualification combining the academic Maturità with a commercial AFC diploma. Graduated above average in both tracks.</p>
|
|
||||||
<ul>
|
|
||||||
<li><strong style={{color:'var(--navy)'}}>Economics & Business</strong> microeconomic principles, business organisation, entrepreneurship, and market dynamics.</li>
|
|
||||||
<li><strong style={{color:'var(--navy)'}}>Accounting & Finance</strong> double-entry bookkeeping, financial statements, cost accounting, and Swiss business law.</li>
|
|
||||||
<li><strong style={{color:'var(--navy)'}}>Mathematics & Statistics</strong> algebra, calculus foundations, probability, and descriptive statistics.</li>
|
|
||||||
<li><strong style={{color:'var(--navy)'}}>Languages</strong> Italian (native instruction), German, English, and French; professional writing and communication.</li>
|
|
||||||
<li>Maturità Cantonale: <strong>4.5 / 6</strong> · AFC Economics: <strong>5 / 6</strong>. Above average in both tracks.</li>
|
|
||||||
</ul>
|
|
||||||
<div className="econ-tags">
|
|
||||||
<span className="econ-tag">Maturità 4.5/6</span><span className="econ-tag">AFC 5/6</span><span className="econ-tag">Accounting</span><span className="econ-tag">Business Law</span><span className="econ-tag">Mathematics</span><span className="econ-tag">Economics</span><span className="econ-tag">Multilingual</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{marginTop:'36px'}}><div className="slbl">Languages</div></div>
|
|
||||||
<div className="langs">
|
|
||||||
<div className="langc"><div className="lang-n">Italian</div><div className="lang-l">Native · C2</div></div>
|
|
||||||
<div className="langc"><div className="lang-n">English</div><div className="lang-l">Fluent · C2</div></div>
|
|
||||||
<div className="langc"><div className="lang-n">German</div><div className="lang-l">Conversational · B1–B2</div></div>
|
|
||||||
<div className="langc"><div className="lang-n">Spanish</div><div className="lang-l">Basic · A1</div></div>
|
|
||||||
<div className="langc"><div className="lang-n">Norwegian</div><div className="lang-l">Basic · A1</div></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="sec-gr"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CONTACT */}
|
|
||||||
<div className="contact" id="contact">
|
|
||||||
<div className="contact-g"></div>
|
|
||||||
<div className="contact-body">
|
|
||||||
<h2>Ready to<br/>collaborate?</h2>
|
|
||||||
<p>Whether you need server infrastructure, network architecture, a self-hosted media stack, or a Junior SysAdmin let’s build something reliable together.</p>
|
|
||||||
<div className="contact-ctas">
|
|
||||||
<a href="mailto:0pietroquintavalle0@gmail.com" className="btn-li">✉ 0pietroquintavalle0@gmail.com</a>
|
|
||||||
<a href="https://github.com/Quinta0" target="_blank" rel="noopener" className="btn-gh">GitHub ↗</a>
|
|
||||||
<a href="https://www.linkedin.com/in/pietro-quintavalle-996b96267/" target="_blank" rel="noopener" className="btn-gh">LinkedIn ↗</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="contact-gr"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FOOTER */}
|
|
||||||
<footer>
|
|
||||||
<div className="foot">
|
|
||||||
<p>© 2026 Pietro Quintavalle · Lugano, Switzerland</p>
|
|
||||||
<a href="#">Back to top ↑</a>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "new-york",
|
|
||||||
"rsc": true,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "tailwind.config.ts",
|
|
||||||
"css": "app/globals.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/utils",
|
|
||||||
"ui": "@/components/ui",
|
|
||||||
"lib": "@/lib",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide"
|
|
||||||
}
|
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Pietro Quintavalle — Bare-metal systems and Linux infrastructure</title>
|
||||||
|
<meta name="description" content="Bare-metal deployments, server rack integration and low-level Linux systems diagnostics by Pietro Quintavalle, Ticino, Switzerland." />
|
||||||
|
<meta name="author" content="Pietro Quintavalle" />
|
||||||
|
<meta name="theme-color" content="#eef0f3" />
|
||||||
|
<meta property="og:title" content="Pietro Quintavalle — Bare-metal systems and Linux infrastructure" />
|
||||||
|
<meta property="og:description" content="Bare-metal deployments, server rack integration and low-level Linux systems diagnostics." />
|
||||||
|
<meta property="og:site_name" content="Pietro Quintavalle" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23141821'/%3E%3Ctext x='32' y='43' text-anchor='middle' font-family='Arial Black,Arial' font-weight='900' font-size='30' fill='%23eef0f3'%3EPQ%3C/text%3E%3C/svg%3E" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@500;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
output: 'export',
|
|
||||||
distDir: 'out',
|
|
||||||
trailingSlash: true,
|
|
||||||
images: {
|
|
||||||
unoptimized: true,
|
|
||||||
domains: ['via.placeholder.com']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default nextConfig
|
|
||||||
Generated
+634
-7286
File diff suppressed because it is too large
Load Diff
+9
-18
@@ -2,33 +2,24 @@
|
|||||||
"name": "pietro-portfolio",
|
"name": "pietro-portfolio",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "tsc --noEmit && vite build",
|
||||||
"start": "next start",
|
"preview": "vite preview",
|
||||||
"lint": "next lint",
|
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^0.441.0",
|
|
||||||
"next": "14.2.13",
|
|
||||||
"radix-ui": "^1.4.2",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"three": "^0.186.0"
|
||||||
"tailwindcss-animate": "^1.0.7"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.16.5",
|
|
||||||
"@types/react": "^18.3.8",
|
"@types/react": "^18.3.8",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"eslint": "^8.57.1",
|
"@types/three": "^0.186.0",
|
||||||
"eslint-config-next": "14.2.13",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
"postcss": "^8.4.47",
|
"typescript": "^5.6.2",
|
||||||
"tailwindcss": "^3.4.12",
|
"vite": "^8.2.1"
|
||||||
"typescript": "^5.6.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
|
||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 96 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 51 KiB |
+173
@@ -0,0 +1,173 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import ParticleScene, { SHAPE_NAMES } from './scene/ParticleScene'
|
||||||
|
import { usePageMotion } from './usePageMotion'
|
||||||
|
import * as C from './content'
|
||||||
|
|
||||||
|
const KEYFRAMES = ['.hero', '#work', '#skills', '#contact']
|
||||||
|
|
||||||
|
function Name({ lines }: { lines: string[] }) {
|
||||||
|
const minLen = Math.min(...lines.map(l => l.length))
|
||||||
|
return (
|
||||||
|
<h1 className="name" id="name" aria-label={lines.join(' ')}>
|
||||||
|
{lines.map((ln, li) => (
|
||||||
|
<span className="ln" key={ln} aria-hidden="true" style={{ '--s': Math.max(.55, minLen / ln.length) } as React.CSSProperties}>
|
||||||
|
{[...ln].map((ch, i) => <span className="ch" key={i} style={{ transitionDelay: `${0.25 + li * 0.35 + i * 0.04}s` }}>{ch}</span>)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</h1>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Marquee({ items, reverse }: { items: string[]; reverse?: boolean }) {
|
||||||
|
const row = [...items, ...items]
|
||||||
|
return <div className={`mq rv${reverse ? ' rev' : ''}`} aria-hidden="true"><div>{row.map((s, i) => <span key={i}>{s}</span>)}</div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
usePageMotion()
|
||||||
|
const [label, setLabel] = useState(SHAPE_NAMES[0])
|
||||||
|
const [swap, setSwap] = useState(false)
|
||||||
|
const barRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// label swaps with a short fade
|
||||||
|
const pending = useRef<number | null>(null)
|
||||||
|
const onLabel = useCallback((text: string) => {
|
||||||
|
setSwap(true)
|
||||||
|
if (pending.current) clearTimeout(pending.current)
|
||||||
|
pending.current = window.setTimeout(() => { setLabel(text); setSwap(false) }, 400)
|
||||||
|
}, [])
|
||||||
|
useEffect(() => () => { if (pending.current) clearTimeout(pending.current) }, [])
|
||||||
|
const onProgress = useCallback((f: number) => { if (barRef.current) barRef.current.style.transform = `scaleX(${f.toFixed(4)})` }, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ParticleScene keyframes={KEYFRAMES} onLabel={onLabel} onProgress={onProgress} />
|
||||||
|
<div className="grain" aria-hidden="true" />
|
||||||
|
<div className="vignette" aria-hidden="true" />
|
||||||
|
<div className="cur" aria-hidden="true" /><div className="cur-ring" aria-hidden="true" />
|
||||||
|
<div className="progress" ref={barRef} aria-hidden="true" />
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<a className="brand" href="#top">PQ</a>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#work">Work</a></li>
|
||||||
|
<li><a href="#oss">Open source</a></li>
|
||||||
|
<li><a href="#projects">Projects</a></li>
|
||||||
|
<li><a href="#contact">Contact</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div className="shape-label" aria-hidden="true"><i /><span className={swap ? 'swap' : ''}>{label}</span></div>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
<section className="hero">
|
||||||
|
<div className="wrap">
|
||||||
|
<p className="intro rv">Monte Carasso, Ticino. Six years at the bench, five years on Linux, four servers in production. Looking for datacenter operations, hardware integration or Linux systems support, on-site or hybrid.</p>
|
||||||
|
<Name lines={['Pietro', 'Quintavalle']} />
|
||||||
|
<div className="tag rv">
|
||||||
|
<p>I build infrastructure from the <em>solder joint</em> up. Component-level diagnostics, ZFS storage, zero-trust networks, and the occasional driver written from a packet dump.</p>
|
||||||
|
</div>
|
||||||
|
<div className="facts rv">
|
||||||
|
{C.facts.map(f => <div key={f.label}><b data-count={f.n} data-prefix={f.prefix}>0</b><small>{f.label}</small></div>)}
|
||||||
|
</div>
|
||||||
|
<div className="hint rv"><b />Scroll. The particles follow.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="qualities">
|
||||||
|
<div className="wrap">
|
||||||
|
<h2 className="rv">What you get when you hire me</h2>
|
||||||
|
<div className="qual rv">
|
||||||
|
{C.qualities.map(q => <article key={q.title}><h3>{q.title}</h3><p>{q.text}</p></article>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="work">
|
||||||
|
<div className="wrap">
|
||||||
|
<h2 className="rv">Experience</h2>
|
||||||
|
<div className="tl rv" id="tl"><div className="beam" />
|
||||||
|
{C.jobs.map(j => (
|
||||||
|
<div className="job" key={j.title} data-shape={j.shape} data-label={j.label}>
|
||||||
|
<time>{j.time}</time>
|
||||||
|
<h3>{j.title}</h3>
|
||||||
|
<span className="org">{j.org}</span>
|
||||||
|
<ul>{j.bullets.map(b => <li key={b}>{b}</li>)}</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="oss">
|
||||||
|
<div className="wrap">
|
||||||
|
<h2 className="rv">Open source, close to the metal</h2>
|
||||||
|
<p className="dim rv" style={{ marginBottom: 40 }}>Upstream contributions to projects that sit between hardware and the kernel. Hover a row and the sculpture becomes the work.</p>
|
||||||
|
<div className="oss rv">
|
||||||
|
{C.oss.map(o => (
|
||||||
|
<a href={o.href} target="_blank" rel="noopener" key={o.href} data-shape={o.shape} data-label={o.label}>
|
||||||
|
<div><span className="repo">{o.repo}</span><h3>{o.title}</h3><p className="what">{o.text}</p></div>
|
||||||
|
<span className="ref">{o.ref}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="projects">
|
||||||
|
<div className="wrap">
|
||||||
|
<h2 className="rv">Selected projects</h2>
|
||||||
|
<div className="proj rv">
|
||||||
|
{C.projects.map(p => (
|
||||||
|
<article key={p.title} data-tilt data-shape={p.shape} data-label={p.label}>
|
||||||
|
<h3>{p.title}{p.star && <> <span className="star">{p.star}</span></>}</h3>
|
||||||
|
<p>{p.text}</p>
|
||||||
|
<div className="stack">{p.stack}</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="skills">
|
||||||
|
<div className="wrap"><h2 className="rv">Tools I reach for</h2></div>
|
||||||
|
<Marquee items={C.toolsRowA} />
|
||||||
|
<Marquee items={C.toolsRowB} reverse />
|
||||||
|
<div className="wrap">
|
||||||
|
<p className="skills-note rv">Also on the bench: Rust, C, C++, Python and SQL for diagnostics, and a self-directed track on local LLM deployment and quantisation on bare-metal hardware with llama.cpp and Claude Code.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="education">
|
||||||
|
<div className="wrap">
|
||||||
|
<h2 className="rv">Education and languages</h2>
|
||||||
|
<div className="edu rv">
|
||||||
|
<dl>{C.education.map(e => <div key={e.title}><dt>{e.title}</dt><dd>{e.text}</dd></div>)}</dl>
|
||||||
|
<div>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Certifications</dt><dd>{C.certifications}</dd></div>
|
||||||
|
<div><dt>Languages</dt><dd className="langs">{C.languages.map(([l, lv]) => <span key={l}><b>{l}</b> {lv}</span>)}</dd></div>
|
||||||
|
<div><dt>Status</dt><dd>Italian national, Swiss Permit C, born 2001.</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="contact" className="contact">
|
||||||
|
<div className="wrap">
|
||||||
|
<h2 className="rv">Need someone who can rack it, image it, and keep it up?</h2>
|
||||||
|
<a className="mail rv" href={`mailto:${C.EMAIL}`}>{C.EMAIL}</a>
|
||||||
|
<div className="links rv">
|
||||||
|
<a data-mag href={C.GITHUB} target="_blank" rel="noopener">GitHub</a>
|
||||||
|
<a data-mag href={C.LINKEDIN} target="_blank" rel="noopener">LinkedIn</a>
|
||||||
|
<a data-mag href={C.WHOAMI} target="_blank" rel="noopener">whoami</a>
|
||||||
|
<a data-mag href={C.GITLAB} target="_blank" rel="noopener">GitLab</a>
|
||||||
|
<a data-mag href={C.CODEBERG} target="_blank" rel="noopener">Codeberg</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<footer><span>Pietro Quintavalle, Ticino, Switzerland</span><span>Built with Three.js, one canvas, fifteen thousand particles.</span></footer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
// All page copy lives here so the layout stays clean
|
||||||
|
export const EMAIL = '0pietroquintavalle0@gmail.com'
|
||||||
|
export const GITHUB = 'https://github.com/Quinta0'
|
||||||
|
export const LINKEDIN = 'https://linkedin.com/in/pietro-quintavalle'
|
||||||
|
export const WHOAMI = 'https://quinta0.github.io/whoami'
|
||||||
|
export const GITLAB = 'https://gitlab.com/Quinta0'
|
||||||
|
export const CODEBERG = 'https://codeberg.org/Quinta'
|
||||||
|
|
||||||
|
export const facts = [
|
||||||
|
{ n: 6, label: 'years assembling and repairing hardware' },
|
||||||
|
{ n: 25, label: 'systems built for private clients' },
|
||||||
|
{ n: 12, label: 'active users on self-hosted infrastructure' },
|
||||||
|
{ n: 30, prefix: '<', label: 'minutes to rebuild a cluster from bare metal' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const qualities = [
|
||||||
|
{ title: 'Diagnoses at the component, not the ticket', text: 'Thermal bench-testing, firmware and BIOS flashing, soldering and harness wiring. When a node fails I find the part, not the symptom.' },
|
||||||
|
{ title: 'Treats every host as disposable', text: 'Ansible playbooks and Docker Compose rebuild the whole environment from a blank disk in under thirty minutes. Nothing important lives only in one place.' },
|
||||||
|
{ title: 'Zero exposed WAN ports, by default', text: 'Cloudflare Tunnels, WireGuard and Tailscale meshes, SSO with 2FA, host firewalls and intrusion detection on every node. Security is part of the build, not a later phase.' },
|
||||||
|
{ title: 'Reads the protocol when there is no documentation', text: 'Reverse-engineered a vendor USB HID protocol from packet captures and shipped a working Linux driver upstream. Undocumented hardware is a puzzle, not a blocker.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export type Shaped = { shape?: string; label?: string }
|
||||||
|
|
||||||
|
export const jobs: (Shaped & { time: string; title: string; org: string; bullets: string[] })[] = [
|
||||||
|
{
|
||||||
|
time: '2026 to now', title: 'Infrastructure and hardware consultant', org: 'DEC Energy, USI startup, freelance',
|
||||||
|
shape: 'cluster', label: 'DEC Energy: two nodes, one GPU, one HBA',
|
||||||
|
bullets: [
|
||||||
|
'Designed, procured and deployed an on-premise two-node Kubernetes cluster from parts to production: local LLM inference, web hosting, accounting and Gitea/GitLab source control.',
|
||||||
|
'Tuned fan curves and ran thermal bench stress tests so the nodes stay quiet in an open office under full inference load.',
|
||||||
|
"Specified and assembled the engineering team's workstations and remain on-call for support.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: 'January 2020 to now', title: 'IT support technician', org: 'Private clients across Ticino, freelance',
|
||||||
|
bullets: [
|
||||||
|
'Built, configured and repaired more than 25 desktop and laptop systems: sourcing, assembly, Windows and Linux deployment, driver debugging, ongoing maintenance.',
|
||||||
|
'Remote and on-site support for hardware failures, software conflicts and local networking faults, with repair logs kept to stop issues recurring.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
time: 'January 2020 to now', title: 'Infrastructure engineer, self-directed', org: 'Four bare-metal servers serving 12+ users',
|
||||||
|
shape: 'homelab', label: 'Homelab as code: nine bays, twelve users, no open ports',
|
||||||
|
bullets: [
|
||||||
|
'UNRAID, Proxmox VE 8, TrueNAS, Docker and LXC running production workloads without any managed hosting.',
|
||||||
|
'Delivered a turnkey rack for a client: cabinet, chassis, structured cabling, TrueNAS with ZFS RAID 10, per-user ACLs and automated offsite snapshots with under thirty minutes of recovery time.',
|
||||||
|
'Hardened every edge: no open WAN ports, SSO and 2FA, intrusion detection, host-level firewalls.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const oss: (Shaped & { href: string; repo: string; title: string; text: string; ref: string })[] = [
|
||||||
|
{
|
||||||
|
href: 'https://github.com/libratbag/libratbag/pull/1873', repo: 'libratbag/libratbag',
|
||||||
|
title: 'A complete USB HID device driver, written from a packet capture',
|
||||||
|
text: "Sniffed the vendor's raw HID reports in Wireshark, decoded the byte layout for DPI profiles, polling rates and on-board memory, then wrote a complete C driver that was merged upstream.",
|
||||||
|
ref: 'PR #1873', shape: 'hid', label: 'HID report: the bytes that became a driver',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://github.com/ValveSoftware/steam-for-linux/issues/13248', repo: 'ValveSoftware/steam-for-linux, Vysp3r/ProtonPlus',
|
||||||
|
title: 'Runtime installation pipeline fault, diagnosed and fixed upstream',
|
||||||
|
text: 'Traced execution crashes in the Steam Linux Runtime to unwritten manifest files, documented a depot recovery workaround and contributed code to automate runtime integrity checks.',
|
||||||
|
ref: 'Issues #13248, #1232',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: 'https://github.com/Recol/DLSS-Updater/pull/283', repo: 'Recol/DLSS-Updater',
|
||||||
|
title: 'Multi-vendor upscaler library detection',
|
||||||
|
text: 'Replaced brittle static tables with bounded manifest parsing so NVIDIA DLSS, AMD FSR and Intel XeSS libraries survive DLL upgrades; also fixed UI state desynchronisation.',
|
||||||
|
ref: 'PRs #283, #252, #256',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const projects: (Shaped & { title: string; star?: string; text: string; stack: string })[] = [
|
||||||
|
{ title: 'Homelab as code', shape: 'homelab', label: 'Homelab as code: nine bays, twelve users, no open ports',
|
||||||
|
text: 'Four production hosts rebuilt from bare metal by Ansible in under thirty minutes. Twelve-plus daily users, no exposed WAN ports: Cloudflare Tunnels, WireGuard, SSO with 2FA and Fail2Ban on every node.',
|
||||||
|
stack: 'Ansible, Docker Compose, UNRAID, Grafana, Prometheus' },
|
||||||
|
{ title: 'Turnkey TrueNAS rack', shape: 'nas', label: 'Client rack: the cabinet that opens this page',
|
||||||
|
text: 'A 19-inch cabinet for a private client: patch panels, storage chassis and nine drive bays, every cable run dressed and labelled. TrueNAS on top with ZFS RAID 10, per-user ACLs and offsite snapshots for a sub-30-minute recovery target.',
|
||||||
|
stack: 'TrueNAS, ZFS, rack infrastructure, structured cabling' },
|
||||||
|
{ title: 'ObsidianSetup', star: '26 stars on GitHub',
|
||||||
|
text: 'Cross-platform provisioning utility for environment configuration and plugin management, adopted by other users.',
|
||||||
|
stack: 'PowerShell' },
|
||||||
|
{ title: 'City simulator, USI group project', shape: 'city', label: 'City simulator: twelve people, one Spring Boot backend',
|
||||||
|
text: 'Coordinated a twelve-person Scrum team building a full-stack city simulation. Owned the Spring Boot backend and its integration with the web frontend.',
|
||||||
|
stack: 'Java, Spring Boot, JavaScript, HTML, CSS' },
|
||||||
|
{ title: 'Nargothrond Watch',
|
||||||
|
text: 'Scheduled health checks for a home server: container status via the Docker API, ZFS pool health, CrowdSec security events and Traefik routes, with an optional self-contained HTML dashboard and email/Discord alerts. No daemon, no database required, just a script on a schedule.',
|
||||||
|
stack: 'Python, SQLite, Docker API, Traefik' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const toolsRowA = ['Proxmox VE', 'TrueNAS', 'ZFS', 'UNRAID', 'Kubernetes', 'Docker', 'LXC', 'KVM', 'Ansible', 'GitHub Actions', 'Bash', 'PowerShell', 'Debian', 'Arch', 'NixOS', 'Windows Server', 'Active Directory']
|
||||||
|
export const toolsRowB = ['WireGuard', 'Tailscale', 'Cloudflare Tunnels', 'UniFi', 'VLANs', 'iptables', 'Fail2Ban', 'AdGuard Home', 'Grafana', 'Prometheus', 'Uptime Kuma', 'Spring Boot', 'TypeScript', 'llama.cpp', 'Rust', 'C']
|
||||||
|
|
||||||
|
export const education = [
|
||||||
|
{ title: 'BSc Economics, English stream', text: 'Università della Svizzera italiana, Lugano, since September 2024. Microeconomics, game theory, quantitative finance, statistics, financial modelling.' },
|
||||||
|
{ title: 'Computer science coursework, two full years', text: 'USI, 2022 to 2024. Algorithms and data structures, object-oriented systems in Java and C++, software architecture, discrete logic.' },
|
||||||
|
{ title: 'Maturità Cantonale and Federal VET Diploma, AFC Economics', text: 'Scuola Cantonale di Commercio, Bellinzona, 2018 to 2022. Maturità 4.5/6, AFC 5/6.' },
|
||||||
|
]
|
||||||
|
export const certifications = 'Google IT Support Professional Certificate. Google Data Analytics Professional Certificate. Microsoft Foundations of IT Systems, Networking and Data Protection.'
|
||||||
|
export const languages = [['Italian', 'native'], ['English', 'C2'], ['German', 'B1–B2'], ['Spanish', 'A1'], ['Norwegian', 'A1']]
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
:root{
|
||||||
|
--bg:#eef0f3; --bg-2:#e3e6eb; --fg:#141821; --muted:#5b6472;
|
||||||
|
--amber:#c86a00; --ice:#2f5fd6; --line:rgba(20,24,33,.14);
|
||||||
|
--display:'Syne',"Helvetica Neue",Arial,sans-serif;
|
||||||
|
--body:'IBM Plex Sans',"Segoe UI",system-ui,sans-serif;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ --bg:#eef0f3; --bg-2:#e3e6eb; --fg:#141821; --muted:#5b6472; } }
|
||||||
|
:root[data-theme="dark"]{ --bg:#eef0f3; --bg-2:#e3e6eb; --fg:#141821; --muted:#5b6472; }
|
||||||
|
:root[data-theme="light"]{ --bg:#eef0f3; --fg:#141821; }
|
||||||
|
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html{scroll-behavior:auto}
|
||||||
|
body{background:var(--bg);color:var(--fg);font-family:var(--body);font-size:17px;line-height:1.55;overflow-x:hidden;-webkit-font-smoothing:antialiased;cursor:none}
|
||||||
|
@media (pointer:coarse){ body{cursor:auto} }
|
||||||
|
a{color:inherit;text-decoration:none}
|
||||||
|
::selection{background:var(--amber);color:#fff}
|
||||||
|
|
||||||
|
#scene{position:fixed;inset:0;z-index:0;display:block}
|
||||||
|
.grain{position:fixed;inset:-100%;z-index:1;pointer-events:none;opacity:.05;
|
||||||
|
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
animation:grain 1.2s steps(6) infinite}
|
||||||
|
@keyframes grain{0%{transform:translate(0,0)}20%{transform:translate(-3%,2%)}40%{transform:translate(2%,-4%)}60%{transform:translate(-4%,-1%)}80%{transform:translate(3%,3%)}100%{transform:translate(0,0)}}
|
||||||
|
.vignette{position:fixed;inset:0;z-index:1;pointer-events:none;background:radial-gradient(ellipse 80% 70% at 50% 45%,transparent 45%,rgba(238,240,243,.7) 100%)}
|
||||||
|
|
||||||
|
/* cursor */
|
||||||
|
.cur,.cur-ring{position:fixed;top:0;left:0;z-index:50;pointer-events:none;border-radius:50%;transform:translate(-50%,-50%);mix-blend-mode:difference}
|
||||||
|
.cur{width:6px;height:6px;background:#fff}
|
||||||
|
.cur-ring{width:34px;height:34px;border:1px solid #fff;transition:width .25s,height .25s,opacity .25s}
|
||||||
|
.cur-ring.big{width:64px;height:64px;opacity:.6}
|
||||||
|
@media (pointer:coarse){ .cur,.cur-ring{display:none} }
|
||||||
|
|
||||||
|
/* nav */
|
||||||
|
nav{position:fixed;top:0;left:0;right:0;z-index:20;display:flex;justify-content:space-between;align-items:center;padding:22px clamp(20px,4vw,56px);font-size:14px;font-weight:500;mix-blend-mode:difference}
|
||||||
|
nav .brand{font-family:var(--display);font-weight:800;letter-spacing:-.02em;font-size:17px}
|
||||||
|
nav ul{display:flex;gap:26px;list-style:none}
|
||||||
|
nav a{position:relative;padding:4px 0}
|
||||||
|
nav a::after{content:"";position:absolute;left:0;right:100%;bottom:0;height:1px;background:#fff;transition:right .3s cubic-bezier(.6,0,.2,1)}
|
||||||
|
nav a:hover::after,nav a:focus-visible::after{right:0}
|
||||||
|
@media (max-width:720px){ nav ul{display:none} }
|
||||||
|
.progress{position:fixed;top:0;left:0;height:2px;width:100%;transform-origin:0 0;transform:scaleX(0);background:var(--amber);z-index:21}
|
||||||
|
|
||||||
|
/* shape label */
|
||||||
|
.shape-label{position:fixed;left:clamp(20px,4vw,56px);bottom:26px;z-index:20;font-size:13px;color:var(--muted);display:flex;align-items:center;gap:12px;pointer-events:none}
|
||||||
|
.shape-label i{width:6px;height:6px;border-radius:50%;background:var(--amber);box-shadow:0 0 10px rgba(200,106,0,.6);animation:blink 2.4s infinite}
|
||||||
|
@keyframes blink{0%,100%{opacity:1}50%{opacity:.25}}
|
||||||
|
.shape-label span{display:inline-block;transition:opacity .4s,transform .4s}
|
||||||
|
.shape-label span.swap{opacity:0;transform:translateY(6px)}
|
||||||
|
|
||||||
|
/* layout */
|
||||||
|
main{position:relative;z-index:2}
|
||||||
|
section{min-height:100vh;padding:20vh clamp(20px,6vw,120px) 12vh;display:flex;flex-direction:column;justify-content:center}
|
||||||
|
.wrap{max-width:1180px;width:100%;margin:0 auto;position:relative}
|
||||||
|
section:not(.hero):not(.contact) .wrap::before{content:"";position:absolute;inset:-60px -80px;z-index:-1;pointer-events:none;
|
||||||
|
background:radial-gradient(ellipse 70% 60% at 38% 50%,rgba(238,240,243,.94) 35%,rgba(238,240,243,.75) 60%,rgba(238,240,243,0) 85%)}
|
||||||
|
h1,h2,h3{font-family:var(--display);letter-spacing:-.03em;line-height:.95}
|
||||||
|
h2{font-size:clamp(38px,6vw,84px);font-weight:800;margin-bottom:.5em;max-width:14ch}
|
||||||
|
h3{font-size:clamp(20px,2.2vw,28px);font-weight:700;letter-spacing:-.02em;line-height:1.15}
|
||||||
|
p.lead{font-size:clamp(18px,1.5vw,22px);max-width:56ch;color:var(--fg)}
|
||||||
|
p.dim{color:var(--muted);max-width:60ch}
|
||||||
|
|
||||||
|
/* reveal: opacity only, one quiet fade */
|
||||||
|
.rv{opacity:0;transition:opacity 1s ease}
|
||||||
|
.rv.in{opacity:1}
|
||||||
|
|
||||||
|
/* hero */
|
||||||
|
.hero{min-height:100vh;padding-top:24vh;justify-content:flex-start;pointer-events:none}
|
||||||
|
.hero .wrap>*{pointer-events:auto}
|
||||||
|
.intro{font-size:15px;color:var(--muted);margin-bottom:2.2em;max-width:44ch}
|
||||||
|
.name{font-size:clamp(56px,12.5vw,190px);font-weight:800;line-height:.86;letter-spacing:-.05em;overflow:hidden}
|
||||||
|
.name .ln{display:block;overflow:hidden;padding-bottom:.06em;white-space:nowrap;font-size:calc(1em * var(--s,1))}
|
||||||
|
.name .ch{display:inline-block;transform:translateY(115%) rotate(6deg);opacity:0;transition:transform 1.1s cubic-bezier(.16,1,.3,1),opacity .6s;transform-origin:0 100%}
|
||||||
|
.name.on .ch{transform:none;opacity:1}
|
||||||
|
.tag{display:flex;flex-wrap:wrap;gap:16px 40px;align-items:flex-end;margin-top:6vh}
|
||||||
|
.tag p{font-size:clamp(18px,1.7vw,24px);max-width:36ch;font-weight:500}
|
||||||
|
.tag p em{font-style:normal;color:var(--amber)}
|
||||||
|
.hint{margin-top:9vh;font-size:13px;color:var(--muted);display:flex;align-items:center;gap:14px}
|
||||||
|
.hint b{display:block;width:1px;height:46px;background:linear-gradient(var(--fg),transparent);animation:drop 1.8s cubic-bezier(.6,0,.2,1) infinite;transform-origin:top}
|
||||||
|
@keyframes drop{0%{transform:scaleY(0);opacity:0}40%{opacity:1}100%{transform:scaleY(1);opacity:0}}
|
||||||
|
|
||||||
|
/* facts row */
|
||||||
|
.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:24px 40px;margin-top:9vh;border-top:1px solid var(--line);padding-top:26px;max-width:900px}
|
||||||
|
.facts div{display:flex;flex-direction:column;gap:4px}
|
||||||
|
.facts b{font-family:var(--display);font-size:clamp(30px,3vw,44px);font-weight:700;letter-spacing:-.03em;line-height:1}
|
||||||
|
.facts small{font-size:13px;color:var(--muted)}
|
||||||
|
|
||||||
|
/* qualities: ruled list, not cards */
|
||||||
|
.qual{display:grid;grid-template-columns:1fr 1fr;gap:0 60px;border-top:1px solid var(--line)}
|
||||||
|
.qual article{padding:34px 0 36px;border-bottom:1px solid var(--line)}
|
||||||
|
.qual article:nth-child(-n+2){border-top:0}
|
||||||
|
.qual h3{margin-bottom:12px;max-width:16ch}
|
||||||
|
.qual p{color:var(--muted);max-width:40ch}
|
||||||
|
@media (max-width:760px){ .qual{grid-template-columns:1fr} }
|
||||||
|
|
||||||
|
/* timeline */
|
||||||
|
.tl{position:relative;padding-left:44px;max-width:860px}
|
||||||
|
.tl::before{content:"";position:absolute;left:8px;top:0;bottom:0;width:1px;background:var(--line)}
|
||||||
|
.tl .beam{position:absolute;left:8px;top:0;width:1px;height:100%;background:linear-gradient(var(--amber),var(--ice));transform:scaleY(0);transform-origin:top;transition:transform 1.6s cubic-bezier(.6,0,.2,1)}
|
||||||
|
.tl.in .beam{transform:scaleY(1)}
|
||||||
|
.job{position:relative;padding:0 0 64px}
|
||||||
|
.job::before{content:"";position:absolute;left:-40px;top:10px;width:9px;height:9px;border-radius:50%;background:var(--bg);border:1px solid var(--fg)}
|
||||||
|
.job time{display:block;font-size:14px;color:var(--muted);margin-bottom:8px}
|
||||||
|
.job h3{margin-bottom:6px}
|
||||||
|
.job .org{font-size:15px;color:var(--amber);margin-bottom:16px;display:block}
|
||||||
|
.job ul{list-style:none;display:grid;gap:10px;max-width:60ch;color:var(--fg)}
|
||||||
|
.job li{padding-left:18px;position:relative;color:#2b3140}
|
||||||
|
.job li::before{content:"";position:absolute;left:0;top:.65em;width:8px;height:1px;background:var(--muted)}
|
||||||
|
|
||||||
|
/* oss */
|
||||||
|
.oss{display:grid;gap:0;border-top:1px solid var(--line);max-width:960px}
|
||||||
|
.oss a{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:20px 40px;align-items:start;padding:32px 0;border-bottom:1px solid var(--line);transition:padding-left .4s cubic-bezier(.16,1,.3,1)}
|
||||||
|
.oss a:hover,.oss a:focus-visible{padding-left:14px}
|
||||||
|
.oss .repo{font-size:14px;color:var(--ice);margin-bottom:8px;display:block}
|
||||||
|
.oss .what{color:var(--muted);max-width:56ch}
|
||||||
|
.oss .ref{font-family:var(--display);font-weight:700;font-size:15px;color:var(--fg);white-space:nowrap;padding-top:2px;opacity:.7}
|
||||||
|
@media (max-width:640px){ .oss a{grid-template-columns:1fr} }
|
||||||
|
|
||||||
|
/* projects: staggered, not a card grid */
|
||||||
|
.proj{display:grid;grid-template-columns:repeat(12,1fr);gap:40px 24px}
|
||||||
|
.proj article{grid-column:span 5;padding:28px 0 0;border-top:1px solid var(--line);position:relative;transform-style:preserve-3d;transition:transform .5s cubic-bezier(.16,1,.3,1)}
|
||||||
|
.proj article:nth-child(2){grid-column:7/span 6;margin-top:80px}
|
||||||
|
.proj article:nth-child(3){grid-column:2/span 5;margin-top:-30px}
|
||||||
|
.proj article:nth-child(4){grid-column:8/span 5;margin-top:20px}
|
||||||
|
.proj article::after{content:"";position:absolute;left:0;top:-1px;width:0;height:1px;background:var(--amber);transition:width .6s cubic-bezier(.16,1,.3,1)}
|
||||||
|
.proj article:hover::after{width:100%}
|
||||||
|
.proj .stack{font-size:13px;color:var(--muted);margin:14px 0 0;line-height:1.8}
|
||||||
|
.proj p{color:#2b3140;max-width:44ch;margin-top:10px}
|
||||||
|
.proj .star{color:var(--amber);font-size:14px}
|
||||||
|
@media (max-width:860px){ .proj article,.proj article:nth-child(n){grid-column:1/-1;margin-top:0} }
|
||||||
|
|
||||||
|
/* skills: two counter-rotating marquees */
|
||||||
|
.mq{overflow:hidden;white-space:nowrap;padding:18px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-top:-1px;font-family:var(--display);font-weight:700;font-size:clamp(22px,3.2vw,44px);letter-spacing:-.02em}
|
||||||
|
.mq div{display:inline-block;animation:mq 60s linear infinite}
|
||||||
|
.mq.rev div{animation-direction:reverse;color:var(--muted)}
|
||||||
|
.mq span{padding:0 .55em}
|
||||||
|
.mq span::after{content:"/";color:var(--amber);padding-left:1.1em;font-weight:500}
|
||||||
|
@keyframes mq{to{transform:translateX(-50%)}}
|
||||||
|
.skills-note{margin-top:40px;color:var(--muted);max-width:60ch}
|
||||||
|
|
||||||
|
/* education */
|
||||||
|
.edu{display:grid;grid-template-columns:1fr 1fr;gap:40px 60px;max-width:960px}
|
||||||
|
.edu dl{display:grid;gap:22px}
|
||||||
|
.edu dt{font-weight:600}
|
||||||
|
.edu dd{color:var(--muted);font-size:15px}
|
||||||
|
.edu .langs{display:flex;flex-wrap:wrap;gap:10px 22px;font-size:15px;color:var(--muted)}
|
||||||
|
.edu .langs b{color:var(--fg);font-weight:500}
|
||||||
|
@media (max-width:760px){ .edu{grid-template-columns:1fr} }
|
||||||
|
|
||||||
|
/* contact */
|
||||||
|
.contact{min-height:100vh;justify-content:center;text-align:left}
|
||||||
|
.contact h2{font-size:clamp(44px,8.5vw,140px);max-width:none;line-height:.9}
|
||||||
|
.mail{display:inline-block;font-family:var(--display);font-weight:700;font-size:clamp(20px,2.6vw,34px);letter-spacing:-.02em;margin-top:20px;position:relative;padding:8px 0}
|
||||||
|
.mail::after{content:"";position:absolute;left:0;bottom:0;height:2px;width:100%;background:var(--amber);transform:scaleX(.18);transform-origin:left;transition:transform .5s cubic-bezier(.16,1,.3,1)}
|
||||||
|
.mail:hover::after,.mail:focus-visible::after{transform:scaleX(1)}
|
||||||
|
.links{display:flex;flex-wrap:wrap;gap:14px;margin-top:48px}
|
||||||
|
.links a{padding:14px 22px;border:1px solid var(--line);border-radius:999px;font-size:15px;font-weight:500;transition:border-color .3s,background .3s;will-change:transform}
|
||||||
|
.links a:hover,.links a:focus-visible{border-color:var(--fg);background:rgba(20,24,33,.05)}
|
||||||
|
footer{padding:40px clamp(20px,6vw,120px);font-size:13px;color:var(--muted);display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px;position:relative;z-index:2}
|
||||||
|
|
||||||
|
:focus-visible{outline:2px solid var(--amber);outline-offset:4px}
|
||||||
|
@media (prefers-reduced-motion:reduce){
|
||||||
|
.grain,.hint b,.shape-label i,.mq div{animation:none}
|
||||||
|
.name .ch{transition:none;transform:none;opacity:1}
|
||||||
|
.rv{transition:none;opacity:1}
|
||||||
|
}
|
||||||
|
#root{display:contents}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { vert, frag } from './shaders'
|
||||||
|
import { shapeRack, shapeKnot, shapeGlobe, shapeText, getHoverShape, hoverFlat } from './shapes'
|
||||||
|
import { hover } from './hoverStore'
|
||||||
|
|
||||||
|
export const SHAPE_NAMES = [
|
||||||
|
'Rack: four production servers, bare metal',
|
||||||
|
'Mesh: zero exposed ports, every node peered',
|
||||||
|
'Perimeter: SSO, 2FA, host firewalls, intrusion detection',
|
||||||
|
'Contact: the whole thing reassembles on request',
|
||||||
|
]
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** CSS selectors of the sections each scroll shape is keyed to, in order */
|
||||||
|
keyframes: string[]
|
||||||
|
onLabel?: (text: string) => void
|
||||||
|
onProgress?: (scrollFraction: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ParticleScene({ keyframes, onLabel, onProgress }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
const coarse = matchMedia('(pointer: coarse)').matches
|
||||||
|
const isMobile = coarse || innerWidth < 760
|
||||||
|
const COUNT = isMobile ? 6000 : 15000
|
||||||
|
|
||||||
|
let renderer: THREE.WebGLRenderer
|
||||||
|
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, powerPreference: 'high-performance' }) }
|
||||||
|
catch { canvas.style.display = 'none'; return }
|
||||||
|
|
||||||
|
const scene = new THREE.Scene()
|
||||||
|
const camera = new THREE.PerspectiveCamera(42, innerWidth / innerHeight, 0.1, 100)
|
||||||
|
camera.position.set(0, 0, 18)
|
||||||
|
const group = new THREE.Group()
|
||||||
|
scene.add(group)
|
||||||
|
|
||||||
|
const g = new THREE.BufferGeometry()
|
||||||
|
const rand = new Float32Array(COUNT); for (let i = 0; i < COUNT; i++) rand[i] = Math.random()
|
||||||
|
g.setAttribute('position', new THREE.BufferAttribute(shapeRack(COUNT), 3))
|
||||||
|
g.setAttribute('aP0', new THREE.BufferAttribute(shapeRack(COUNT), 3))
|
||||||
|
g.setAttribute('aP1', new THREE.BufferAttribute(shapeKnot(COUNT), 3))
|
||||||
|
g.setAttribute('aP2', new THREE.BufferAttribute(shapeGlobe(COUNT), 3))
|
||||||
|
g.setAttribute('aP3', new THREE.BufferAttribute(shapeText(COUNT), 3))
|
||||||
|
g.setAttribute('aPH', new THREE.BufferAttribute(new Float32Array(COUNT * 3), 3))
|
||||||
|
g.setAttribute('aPH2', new THREE.BufferAttribute(new Float32Array(COUNT * 3), 3))
|
||||||
|
g.setAttribute('aRand', new THREE.BufferAttribute(rand, 1))
|
||||||
|
const uniforms = {
|
||||||
|
uProgress: { value: 0 }, uTime: { value: 0 }, uPR: { value: Math.min(devicePixelRatio, 2) },
|
||||||
|
uMouse: { value: new THREE.Vector3(999, 999, 0) }, uMouseOn: { value: 0 }, uIntro: { value: reduce ? 1 : 0 },
|
||||||
|
uDim: { value: 1 }, uHover: { value: 0 }, uSwap: { value: 1 },
|
||||||
|
uC1: { value: new THREE.Color('#1f3d9e') }, uC2: { value: new THREE.Color('#d9741a') },
|
||||||
|
}
|
||||||
|
const material = new THREE.ShaderMaterial({ uniforms, vertexShader: vert, fragmentShader: frag, transparent: true, depthWrite: false, blending: THREE.NormalBlending })
|
||||||
|
const points = new THREE.Points(g, material)
|
||||||
|
group.add(points)
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
|
||||||
|
renderer.setSize(innerWidth, innerHeight, false)
|
||||||
|
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix()
|
||||||
|
camera.position.z = innerWidth < 760 ? 26 : 18
|
||||||
|
}
|
||||||
|
|
||||||
|
// scroll: real fraction for the bar, section-keyed progress for the morph
|
||||||
|
let scrollT = 0, scrollCur = 0, progT = 0, progCur = 0
|
||||||
|
let mx = 0, my = 0, mxCur = 0, myCur = 0, mouseOn = 0, mouseOnCur = 0
|
||||||
|
let hoverT = 0, hoverCur = 0, swapCur = 1, hoverKey: string | null = null
|
||||||
|
const updateScroll = () => {
|
||||||
|
const max = document.documentElement.scrollHeight - innerHeight
|
||||||
|
scrollT = max > 0 ? scrollY / max : 0
|
||||||
|
const els = keyframes.map(q => document.querySelector<HTMLElement>(q)).filter(Boolean) as HTMLElement[]
|
||||||
|
if (els.length < 2) return
|
||||||
|
const c = els.map(el => el.offsetTop + el.offsetHeight / 2 - innerHeight / 2), y = scrollY, last = els.length - 1
|
||||||
|
let v = last
|
||||||
|
if (y <= c[0]) v = 0
|
||||||
|
else for (let i = 0; i < last; i++) { if (y < c[i + 1]) { v = i + THREE.MathUtils.smoothstep((y - c[i]) / Math.max(1, c[i + 1] - c[i]), 0.45, 1); break } }
|
||||||
|
progT = v / 3
|
||||||
|
}
|
||||||
|
const onMove = (e: PointerEvent) => { mx = (e.clientX / innerWidth) * 2 - 1; my = -(e.clientY / innerHeight) * 2 + 1; mouseOn = 1 }
|
||||||
|
const onLeave = () => { mouseOn = 0 }
|
||||||
|
const onResize = () => { resize(); updateScroll() }
|
||||||
|
addEventListener('resize', onResize)
|
||||||
|
addEventListener('scroll', updateScroll, { passive: true })
|
||||||
|
addEventListener('pointermove', onMove, { passive: true })
|
||||||
|
document.addEventListener('mouseleave', onLeave)
|
||||||
|
|
||||||
|
// hover shapes come from the shared store; swapping cross-fades through a scatter
|
||||||
|
const applyHover = () => {
|
||||||
|
if (!hover.on) { hoverT = 0; return }
|
||||||
|
const k = hover.key!
|
||||||
|
if (hoverKey !== k) {
|
||||||
|
const ph = g.getAttribute('aPH') as THREE.BufferAttribute, ph2 = g.getAttribute('aPH2') as THREE.BufferAttribute
|
||||||
|
if (hoverKey) { (ph2.array as Float32Array).set(ph.array as Float32Array); ph2.needsUpdate = true; swapCur = 0 }
|
||||||
|
else { (ph2.array as Float32Array).set(getHoverShape(k, COUNT)); ph2.needsUpdate = true; swapCur = 1 }
|
||||||
|
;(ph.array as Float32Array).set(getHoverShape(k, COUNT)); ph.needsUpdate = true; hoverKey = k
|
||||||
|
}
|
||||||
|
hoverT = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0), ray = new THREE.Raycaster(), hit = new THREE.Vector3(), ndc = new THREE.Vector2()
|
||||||
|
let last = performance.now(), elapsed = 0, introT = 0, labelIdx = -1, lastLabel = ''
|
||||||
|
let raf = 0
|
||||||
|
const frame = (now: number) => {
|
||||||
|
raf = requestAnimationFrame(frame)
|
||||||
|
const dt = Math.min((now - last) / 1000, 0.05); last = now; elapsed += dt
|
||||||
|
const t = elapsed
|
||||||
|
applyHover()
|
||||||
|
scrollCur += (scrollT - scrollCur) * 0.06; progCur += (progT - progCur) * 0.07
|
||||||
|
mxCur += (mx - mxCur) * 0.08; myCur += (my - myCur) * 0.08
|
||||||
|
mouseOnCur += (mouseOn - mouseOnCur) * 0.1
|
||||||
|
if (!reduce) { introT = Math.min(1, introT + dt * 0.55); uniforms.uIntro.value = 1 - Math.pow(1 - introT, 3) }
|
||||||
|
|
||||||
|
const prog = progCur * 3
|
||||||
|
uniforms.uProgress.value = prog
|
||||||
|
uniforms.uTime.value = t
|
||||||
|
|
||||||
|
group.rotation.y = scrollCur * Math.PI * 1.35 + t * 0.03
|
||||||
|
group.rotation.x = Math.sin(scrollCur * Math.PI) * 0.35
|
||||||
|
const face = THREE.MathUtils.smoothstep(prog, 2.55, 3.0)
|
||||||
|
group.rotation.y = THREE.MathUtils.lerp(group.rotation.y, Math.round(group.rotation.y / (Math.PI * 2)) * Math.PI * 2, face)
|
||||||
|
group.rotation.x = THREE.MathUtils.lerp(group.rotation.x, 0, face)
|
||||||
|
|
||||||
|
hoverCur += (hoverT - hoverCur) * 0.06; swapCur += (1 - swapCur) * 0.08
|
||||||
|
const hEase = hoverCur * hoverCur * (3 - 2 * hoverCur)
|
||||||
|
uniforms.uHover.value = hEase; uniforms.uSwap.value = swapCur
|
||||||
|
if (hoverCur > 0.001) {
|
||||||
|
const flat = hoverKey ? hoverFlat[hoverKey] : false
|
||||||
|
const ty = (flat ? 0 : 0.62) + t * 0.08, tx = flat ? 0 : 0.3
|
||||||
|
const near = ty + Math.round((group.rotation.y - ty) / (Math.PI * 2)) * Math.PI * 2
|
||||||
|
group.rotation.y = THREE.MathUtils.lerp(group.rotation.y, near, hEase)
|
||||||
|
group.rotation.x = THREE.MathUtils.lerp(group.rotation.x, tx, hEase)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mid = THREE.MathUtils.smoothstep(prog, 0.15, 0.6) * (1 - THREE.MathUtils.smoothstep(prog, 2.5, 2.95))
|
||||||
|
const offX = innerWidth < 760 ? 0 : THREE.MathUtils.lerp(5.5, 4.5, THREE.MathUtils.smoothstep(prog, 0, 0.8)) * (1 - THREE.MathUtils.smoothstep(prog, 2.5, 2.95))
|
||||||
|
const hx = innerWidth < 760 ? 0 : 3.6, hz = innerWidth < 760 ? 2 : 5.5
|
||||||
|
group.position.x += (THREE.MathUtils.lerp(offX, hx, hEase) - group.position.x) * 0.05
|
||||||
|
group.position.z += (hz * hEase - group.position.z) * 0.05
|
||||||
|
uniforms.uDim.value = THREE.MathUtils.lerp(1 - mid * 0.55, 1, hEase)
|
||||||
|
|
||||||
|
camera.position.x += (mxCur * 0.9 - camera.position.x) * 0.05
|
||||||
|
camera.position.y += (myCur * 0.6 - camera.position.y) * 0.05
|
||||||
|
camera.lookAt(0, 0, 0)
|
||||||
|
|
||||||
|
ndc.set(mxCur, myCur); ray.setFromCamera(ndc, camera)
|
||||||
|
if (ray.ray.intersectPlane(plane, hit)) { group.worldToLocal(hit); uniforms.uMouse.value.copy(hit) }
|
||||||
|
uniforms.uMouseOn.value = coarse ? 0 : mouseOnCur
|
||||||
|
|
||||||
|
// label + progress callbacks
|
||||||
|
const label = hover.on && hover.label ? hover.label : SHAPE_NAMES[Math.min(3, Math.round(progCur * 3))]
|
||||||
|
if (label !== lastLabel) { lastLabel = label; labelIdx = -1; onLabel?.(label) }
|
||||||
|
void labelIdx
|
||||||
|
onProgress?.(scrollCur)
|
||||||
|
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
const boot = () => { resize(); updateScroll(); raf = requestAnimationFrame(frame) }
|
||||||
|
// wait for the display font so the "PQ" sampling uses it
|
||||||
|
const ready = document.fonts?.load ? Promise.race([document.fonts.load('800 300px Syne'), new Promise(r => setTimeout(r, 1500))]) : Promise.resolve()
|
||||||
|
ready.then(boot, boot)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf)
|
||||||
|
removeEventListener('resize', onResize); removeEventListener('scroll', updateScroll)
|
||||||
|
removeEventListener('pointermove', onMove); document.removeEventListener('mouseleave', onLeave)
|
||||||
|
g.dispose(); material.dispose(); renderer.dispose()
|
||||||
|
}
|
||||||
|
}, [keyframes, onLabel, onProgress])
|
||||||
|
|
||||||
|
return <canvas id="scene" ref={canvasRef} aria-hidden="true" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Tiny shared state between the page and the particle scene
|
||||||
|
export type HoverState = { key: string | null; label: string | null; on: boolean }
|
||||||
|
export const hover: HoverState = { key: null, label: null, on: false }
|
||||||
|
export function hoverIn(key: string, label: string) { hover.key = key; hover.label = label; hover.on = true }
|
||||||
|
export function hoverOut() { hover.on = false }
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// GLSL for the particle sculpture
|
||||||
|
export const noiseGLSL = `
|
||||||
|
vec3 mod289(vec3 x){return x-floor(x*(1.0/289.0))*289.0;}
|
||||||
|
vec4 mod289(vec4 x){return x-floor(x*(1.0/289.0))*289.0;}
|
||||||
|
vec4 permute(vec4 x){return mod289(((x*34.0)+1.0)*x);}
|
||||||
|
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159-0.85373472095314*r;}
|
||||||
|
float snoise(vec3 v){
|
||||||
|
const vec2 C=vec2(1.0/6.0,1.0/3.0); const vec4 D=vec4(0.0,0.5,1.0,2.0);
|
||||||
|
vec3 i=floor(v+dot(v,C.yyy)); vec3 x0=v-i+dot(i,C.xxx);
|
||||||
|
vec3 g=step(x0.yzx,x0.xyz); vec3 l=1.0-g; vec3 i1=min(g.xyz,l.zxy); vec3 i2=max(g.xyz,l.zxy);
|
||||||
|
vec3 x1=x0-i1+C.xxx; vec3 x2=x0-i2+C.yyy; vec3 x3=x0-D.yyy;
|
||||||
|
i=mod289(i);
|
||||||
|
vec4 p=permute(permute(permute(i.z+vec4(0.0,i1.z,i2.z,1.0))+i.y+vec4(0.0,i1.y,i2.y,1.0))+i.x+vec4(0.0,i1.x,i2.x,1.0));
|
||||||
|
float n_=0.142857142857; vec3 ns=n_*D.wyz-D.xzx;
|
||||||
|
vec4 j=p-49.0*floor(p*ns.z*ns.z); vec4 x_=floor(j*ns.z); vec4 y_=floor(j-7.0*x_);
|
||||||
|
vec4 x=x_*ns.x+ns.yyyy; vec4 y=y_*ns.x+ns.yyyy; vec4 h=1.0-abs(x)-abs(y);
|
||||||
|
vec4 b0=vec4(x.xy,y.xy); vec4 b1=vec4(x.zw,y.zw);
|
||||||
|
vec4 s0=floor(b0)*2.0+1.0; vec4 s1=floor(b1)*2.0+1.0; vec4 sh=-step(h,vec4(0.0));
|
||||||
|
vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy; vec4 a1=b1.xzyw+s1.xzyw*sh.zzww;
|
||||||
|
vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y); vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w);
|
||||||
|
vec4 norm=taylorInvSqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3)));
|
||||||
|
p0*=norm.x;p1*=norm.y;p2*=norm.z;p3*=norm.w;
|
||||||
|
vec4 m=max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.0); m=m*m;
|
||||||
|
return 42.0*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
|
||||||
|
}`;
|
||||||
|
|
||||||
|
export const vert = noiseGLSL + `
|
||||||
|
attribute vec3 aP0; attribute vec3 aP1; attribute vec3 aP2; attribute vec3 aP3; attribute vec3 aPH; attribute vec3 aPH2; attribute float aRand;
|
||||||
|
uniform float uProgress; uniform float uTime; uniform float uPR; uniform vec3 uMouse; uniform float uMouseOn; uniform float uIntro; uniform float uDim; uniform float uHover; uniform float uSwap;
|
||||||
|
varying float vA; varying float vHeat;
|
||||||
|
void main(){
|
||||||
|
float p=clamp(uProgress,0.0,3.0);
|
||||||
|
float s01=smoothstep(0.0,1.0,p), s12=smoothstep(1.0,2.0,p), s23=smoothstep(2.0,3.0,p);
|
||||||
|
vec3 pos = aP0*(1.0-s01) + aP1*(s01-s12) + aP2*(s12-s23) + aP3*s23;
|
||||||
|
float f=fract(p); float tr = (p>=3.0)?0.0:f*(1.0-f)*4.0; // 0 at rest, 1 mid-morph
|
||||||
|
// hover: the sculpture becomes the project under the cursor
|
||||||
|
vec3 hp = mix(aPH2, aPH, uSwap);
|
||||||
|
pos = mix(pos, hp, uHover);
|
||||||
|
tr = tr*(1.0-uHover) + max(uHover*(1.0-uHover)*4.0, uSwap*(1.0-uSwap)*4.0)*0.7;
|
||||||
|
vec3 nv = vec3(snoise(pos*0.45+uTime*0.12), snoise(pos*0.45+vec3(13.7)+uTime*0.12), snoise(pos*0.45+vec3(71.2)+uTime*0.12));
|
||||||
|
pos += nv*(0.07*(1.0-uHover*0.85) + tr*(2.2+aRand*2.0));
|
||||||
|
// intro: particles fall in from a scattered cloud
|
||||||
|
vec3 spread = (aRand-0.5)*vec3(30.0,20.0,30.0) + nv*6.0;
|
||||||
|
pos = mix(spread, pos, uIntro);
|
||||||
|
// mouse repulsion
|
||||||
|
vec3 d = pos - uMouse; float dist=length(d);
|
||||||
|
float rep = smoothstep(3.2,0.0,dist)*uMouseOn;
|
||||||
|
pos += (d/max(dist,0.001))*rep*1.6;
|
||||||
|
vec4 mv = modelViewMatrix*vec4(pos,1.0);
|
||||||
|
gl_Position = projectionMatrix*mv;
|
||||||
|
float size = (0.8+aRand*1.4)*uPR*(1.0+tr*1.0+rep*1.4);
|
||||||
|
gl_PointSize = min(size*(200.0/-mv.z), 9.0*uPR);
|
||||||
|
vA = (0.35+0.55*aRand)*uIntro*uDim*(1.0-tr*0.45);
|
||||||
|
vHeat = clamp(tr*0.9+rep+aRand*0.25,0.0,1.0);
|
||||||
|
}`;
|
||||||
|
export const frag = `
|
||||||
|
uniform vec3 uC1; uniform vec3 uC2; varying float vA; varying float vHeat;
|
||||||
|
void main(){
|
||||||
|
vec2 uv=gl_PointCoord-0.5; float d=length(uv); if(d>0.5) discard;
|
||||||
|
float a=smoothstep(0.5,0.12,d);
|
||||||
|
vec3 c=mix(uC1,uC2,vHeat);
|
||||||
|
gl_FragColor=vec4(c, a*vA);
|
||||||
|
}`;
|
||||||
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// Point-cloud generators: the four scroll shapes and the hover dioramas
|
||||||
|
const rnd = (a = 1, b = 0): number =>Math.random()*(a-b)+b;
|
||||||
|
|
||||||
|
/* Shape 0 — a server rack: stacked chassis outlines, drive bays, cable loom */
|
||||||
|
export function shapeRack(n: number): Float32Array {
|
||||||
|
const a = new Float32Array(n*3);
|
||||||
|
const W=4.6, D=3.2, units=9, H=0.72, y0=-(units*H)/2;
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
let x,y,z; const r=Math.random();
|
||||||
|
if(r<0.42){ // horizontal chassis edges
|
||||||
|
const u=Math.floor(rnd(units+1)), edge=Math.floor(rnd(4)), t=rnd(1);
|
||||||
|
y=y0+u*H;
|
||||||
|
if(edge===0){x=-W/2+t*W;z=D/2}else if(edge===1){x=-W/2+t*W;z=-D/2}else if(edge===2){x=W/2;z=-D/2+t*D}else{x=-W/2;z=-D/2+t*D}
|
||||||
|
} else if(r<0.55){ // vertical rails
|
||||||
|
const c=Math.floor(rnd(4)); x=(c%2?1:-1)*W/2; z=(c<2?1:-1)*D/2; y=y0+rnd(units*H);
|
||||||
|
} else if(r<0.88){ // front face drive bays, dense
|
||||||
|
x=rnd(W/2-0.15,-W/2+0.15); const u=Math.floor(rnd(units)); const bay=Math.floor(rnd(3));
|
||||||
|
y=y0+u*H+0.12+bay*0.2+rnd(0.06); z=D/2+0.02;
|
||||||
|
} else { // rear cable loom drooping
|
||||||
|
const t=rnd(1); x=rnd(0.6,-0.6)+Math.sin(t*9+i)*0.25; y=y0+t*units*H; z=-D/2-0.35-Math.sin(t*3.1)*0.3;
|
||||||
|
}
|
||||||
|
a[i*3]=x;a[i*3+1]=y;a[i*3+2]=z;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Shape 1 — torus knot: the mesh network */
|
||||||
|
export function shapeKnot(n: number): Float32Array {
|
||||||
|
const a=new Float32Array(n*3), p=2,q=3, R=3.0, r=1.35, tube=0.55;
|
||||||
|
const P=(t: number)=>{const c=R+r*Math.cos(q*t);return [c*Math.cos(p*t),c*Math.sin(p*t),r*Math.sin(q*t)];};
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
const t=rnd(Math.PI*2), c=P(t), c2=P(t+0.001);
|
||||||
|
let tx=c2[0]-c[0],ty=c2[1]-c[1],tz=c2[2]-c[2]; const tl=Math.hypot(tx,ty,tz); tx/=tl;ty/=tl;tz/=tl;
|
||||||
|
// normal ~ toward the ring centre, then binormal = T x N
|
||||||
|
let nx=-c[0],ny=-c[1],nz=0; const dn=tx*nx+ty*ny; nx-=tx*dn;ny-=ty*dn;nz-=tz*dn; const nl=Math.hypot(nx,ny,nz)||1; nx/=nl;ny/=nl;nz/=nl;
|
||||||
|
const bx=ty*nz-tz*ny, by=tz*nx-tx*nz, bz=tx*ny-ty*nx;
|
||||||
|
const ang=rnd(Math.PI*2), rad=Math.random()<0.7?tube:tube*Math.sqrt(Math.random());
|
||||||
|
const ca=Math.cos(ang)*rad, sa=Math.sin(ang)*rad;
|
||||||
|
a[i*3]=c[0]+nx*ca+bx*sa; a[i*3+1]=c[1]+ny*ca+by*sa; a[i*3+2]=c[2]+nz*ca+bz*sa;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Shape 2 — globe with latitude bands + a defensive shell: the zero-trust perimeter */
|
||||||
|
export function shapeGlobe(n: number): Float32Array {
|
||||||
|
const a=new Float32Array(n*3);
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
const r=Math.random();
|
||||||
|
let R=4.2, theta, phi;
|
||||||
|
if(r<0.6){ // lat/long grid
|
||||||
|
const useLat=Math.random()<0.5;
|
||||||
|
if(useLat){ phi=Math.acos(1-2*(Math.floor(rnd(13))/12)); theta=rnd(Math.PI*2); }
|
||||||
|
else { theta=Math.floor(rnd(24))*(Math.PI/12); phi=Math.acos(1-2*Math.random()); }
|
||||||
|
} else if(r<0.85){ phi=Math.acos(1-2*Math.random()); theta=rnd(Math.PI*2); R=4.2+rnd(0.05); }
|
||||||
|
else { phi=Math.acos(1-2*Math.random()); theta=rnd(Math.PI*2); R=5.6+rnd(0.15); } // outer shell
|
||||||
|
a[i*3]=R*Math.sin(phi)*Math.cos(theta); a[i*3+1]=R*Math.cos(phi); a[i*3+2]=R*Math.sin(phi)*Math.sin(theta);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Canvas sampler: anything drawn white on black becomes particles */
|
||||||
|
function sampleCanvas(n: number, w: number, h: number, draw: (ctx: CanvasRenderingContext2D) => void, S: number, zSpread: number): Float32Array {
|
||||||
|
const c=document.createElement('canvas'); c.width=w; c.height=h;
|
||||||
|
const ctx=c.getContext('2d')!; ctx.fillStyle='#000'; ctx.fillRect(0,0,w,h); ctx.fillStyle='#fff'; ctx.strokeStyle='#fff';
|
||||||
|
draw(ctx);
|
||||||
|
const img=ctx.getImageData(0,0,w,h).data, pts: number[]=[];
|
||||||
|
for(let y=0;y<h;y+=2)for(let x=0;x<w;x+=2){ if(img[(y*w+x)*4]>128) pts.push(x,y); }
|
||||||
|
const a=new Float32Array(n*3);
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
const k=Math.floor(rnd(pts.length/2))*2;
|
||||||
|
a[i*3]=(pts[k]-w/2)*S+rnd(0.04,-0.04); a[i*3+1]=-(pts[k+1]-h/2)*S+rnd(0.04,-0.04);
|
||||||
|
a[i*3+2]=rnd(zSpread,-zSpread)*(Math.random()<0.15?1:0.15);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Shape 3 — the initials */
|
||||||
|
export function shapeText(n: number): Float32Array {
|
||||||
|
// scale kept small enough that the full width still fits the camera frustum on
|
||||||
|
// narrow/portrait viewports, where it's forced face-on to stay readable
|
||||||
|
return sampleCanvas(n,720,360,ctx=>{ ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.font='800 300px Syne, "Helvetica Neue", Arial, sans-serif'; ctx.fillText('PQ',360,194); },0.012,0.9);
|
||||||
|
}
|
||||||
|
/* Primitive builder for the project dioramas: boxes as edges or solid fill, plus lines */
|
||||||
|
type Prim = { t: 'edges' | 'fill'; c: number[]; s: number[]; w: number } | { t: 'line'; a: number[]; b: number[]; w: number }
|
||||||
|
function fromPrims(n: number, prims: Prim[], scale: number): Float32Array {
|
||||||
|
const tot=prims.reduce((t,p)=>t+p.w,0), a=new Float32Array(n*3);
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
let r=Math.random()*tot, pr=prims[0]; for(const q of prims){ pr=q; if(r<q.w)break; r-=q.w; }
|
||||||
|
let x=0,y=0,z=0;
|
||||||
|
if(pr.t==='line'){ const u=rnd(1); x=pr.a[0]+(pr.b[0]-pr.a[0])*u; y=pr.a[1]+(pr.b[1]-pr.a[1])*u; z=pr.a[2]+(pr.b[2]-pr.a[2])*u; }
|
||||||
|
else {
|
||||||
|
const [cx,cy,cz]=pr.c,[w,h,d]=pr.s;
|
||||||
|
if(pr.t==='edges'){ const e=Math.floor(rnd(12)), u=rnd(1)-0.5, s1=(e&1)?0.5:-0.5, s2=(e&2)?0.5:-0.5;
|
||||||
|
if(e<4){x=u*w;y=s1*h;z=s2*d} else if(e<8){x=s1*w;y=u*h;z=s2*d} else {x=s1*w;y=s2*h;z=u*d} }
|
||||||
|
else { x=(rnd(1)-0.5)*w; y=(rnd(1)-0.5)*h; z=(rnd(1)-0.5)*d; }
|
||||||
|
x+=cx;y+=cy;z+=cz;
|
||||||
|
}
|
||||||
|
a[i*3]=x*scale;a[i*3+1]=y*scale;a[i*3+2]=z*scale;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
export const hoverShapes: Record<string, (n: number) => Float32Array> = {
|
||||||
|
homelab(n: number){ // the homelab chassis: nine bays, copper strip, grille top
|
||||||
|
const pr: Prim[]=[{t:'edges',c:[0,0,0],s:[2.3,2.4,2.6],w:3},{t:'fill',c:[0,1.16,0],s:[2.15,0.01,2.45],w:0.9},{t:'fill',c:[0,0.25,1.32],s:[2.3,0.22,0.06],w:0.9}];
|
||||||
|
const bw=0.17,g=0.02,x0=-(9*bw+8*g)/2+bw/2;
|
||||||
|
for(let i=0;i<9;i++){ const x=x0+i*(bw+g); pr.push({t:'edges',c:[x,-0.43,1.3],s:[bw,1.05,0.18],w:0.35}); pr.push({t:'fill',c:[x,-0.43,1.12],s:[bw-0.04,0.9,0.3],w:0.32}); }
|
||||||
|
return fromPrims(n,pr,2.1);
|
||||||
|
},
|
||||||
|
cluster(n: number){ // DEC Energy: two boards, GPU on one, HBA on the other, bridged
|
||||||
|
const pr: Prim[]=[];
|
||||||
|
for(const bx of [-0.95,0.95]){ pr.push({t:'fill',c:[bx,0,0],s:[1.5,0.08,1.8],w:0.7},{t:'edges',c:[bx,0,0],s:[1.5,0.08,1.8],w:1.6},{t:'fill',c:[bx,0.1,-0.25],s:[0.45,0.12,0.45],w:0.5}); }
|
||||||
|
pr.push({t:'fill',c:[-0.45,0.25,0.1],s:[0.12,0.42,1.2],w:1.0},{t:'fill',c:[0.45,0.22,0.1],s:[0.12,0.35,1.0],w:0.8});
|
||||||
|
pr.push({t:'line',a:[-0.5,0.12,-0.25],b:[0.5,0.12,-0.25],w:0.5});
|
||||||
|
for(const bx of [-0.95,0.95]) for(let k=0;k<3;k++) pr.push({t:'line',a:[bx-0.55,0.05,0.5+k*0.2],b:[bx+0.55,0.05,0.5+k*0.2],w:0.12});
|
||||||
|
return fromPrims(n,pr,2.4);
|
||||||
|
},
|
||||||
|
hosts(n: number){ // four hosts on the floor, one hub, one tunnel out
|
||||||
|
const pr: Prim[]=[]; const hosts=[[-1.3,-1.2,-1.0],[1.3,-1.2,-1.0],[-1.3,-1.2,1.0],[1.3,-1.2,1.0]];
|
||||||
|
for(const h of hosts){ pr.push({t:'edges',c:h,s:[1.7,0.5,1.4],w:1},{t:'fill',c:[h[0],h[1],h[2]+0.68],s:[1.5,0.32,0.05],w:0.45},{t:'line',a:[h[0],h[1]+0.25,h[2]],b:[0,0.6,0],w:0.35}); }
|
||||||
|
pr.push({t:'fill',c:[0,0.6,0],s:[0.45,0.45,0.45],w:0.6},{t:'line',a:[0,0.6,0],b:[0,2.6,0],w:0.5},{t:'edges',c:[0,2.9,0],s:[1.2,0.5,0.9],w:0.5});
|
||||||
|
return fromPrims(n,pr,1.55);
|
||||||
|
},
|
||||||
|
city(n: number){ // the simulated city: a grid of blocks
|
||||||
|
const pr: Prim[]=[{t:'edges',c:[0,-0.05,0],s:[9.2,0.02,9.2],w:1.2}]; let seed=7; const rr=()=>{seed=(seed*16807)%2147483647;return seed/2147483647;};
|
||||||
|
for(let i=0;i<8;i++)for(let j=0;j<8;j++){ const hgt=0.3+rr()*rr()*3.2, x=-3.85+i*1.1, z=-3.85+j*1.1; pr.push({t:'edges',c:[x,hgt/2,z],s:[0.8,hgt,0.8],w:0.35+hgt*0.15}); if(rr()<0.35)pr.push({t:'fill',c:[x,hgt/2,z],s:[0.78,hgt,0.78],w:0.08}); }
|
||||||
|
return fromPrims(n,pr,0.72);
|
||||||
|
},
|
||||||
|
hid(n: number){ // the libratbag report: header, command, profile, DPI, then upstream
|
||||||
|
return sampleCanvas(n,1000,420,ctx=>{
|
||||||
|
ctx.lineWidth=5; ctx.beginPath(); ctx.moveTo(40,40); ctx.lineTo(960,40); ctx.stroke();
|
||||||
|
ctx.setLineDash([8,10]); ctx.beginPath(); ctx.moveTo(110,40); ctx.lineTo(160,130); ctx.moveTo(420,40); ctx.lineTo(380,130); ctx.stroke(); ctx.setLineDash([]);
|
||||||
|
const boxes=[[60,120,'0x08'],[180,120,'0x14'],[300,140,'0x01 02'],[440,190,'0x0640'],[630,300,'00 00 …']];
|
||||||
|
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.font='700 42px Syne, Arial, sans-serif';
|
||||||
|
for(const [x,w,t] of boxes as [number,number,string][]){ ctx.lineWidth=(t==='0x0640')?10:5; ctx.strokeRect(x,130,w,110); ctx.fillText(t,x+w/2,185); }
|
||||||
|
ctx.lineWidth=5; ctx.beginPath(); ctx.moveTo(535,240); ctx.lineTo(535,300); ctx.stroke();
|
||||||
|
ctx.fillRect(360,300,350,70); ctx.fillStyle='#000'; ctx.font='700 30px Syne, Arial, sans-serif'; ctx.fillText('MERGED UPSTREAM',535,336);
|
||||||
|
ctx.fillStyle='#fff'; ctx.font='500 26px Syne, Arial, sans-serif'; ctx.textAlign='left';
|
||||||
|
ctx.fillText('REPORT',60,275); ctx.fillText('CMD',180,275); ctx.fillText('PROFILE 2',300,275); ctx.fillText('1600 DPI',440,275);
|
||||||
|
},0.0142,0.5);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
hoverShapes.nas=shapeRack; // the client rack is the same one that opens the page
|
||||||
|
export const hoverFlat: Record<string, boolean> = { hid: true };
|
||||||
|
const shapeCache: Record<string, Float32Array> = {};
|
||||||
|
export function getHoverShape(k: string, count: number): Float32Array { return shapeCache[k] || (shapeCache[k] = hoverShapes[k](count)); }
|
||||||
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { hoverIn, hoverOut } from './scene/hoverStore'
|
||||||
|
|
||||||
|
// Everything in the page that moves: name reveal, counters, fades, tilt, magnetic links, cursor, hover wiring.
|
||||||
|
export function usePageMotion() {
|
||||||
|
useEffect(() => {
|
||||||
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
const coarse = matchMedia('(pointer: coarse)').matches
|
||||||
|
const cleanups: (() => void)[] = []
|
||||||
|
|
||||||
|
// hero name
|
||||||
|
const nameT = setTimeout(() => document.getElementById('name')?.classList.add('on'), 150)
|
||||||
|
cleanups.push(() => clearTimeout(nameT))
|
||||||
|
|
||||||
|
// counters
|
||||||
|
const countUp = (el: HTMLElement) => {
|
||||||
|
const target = +el.dataset.count!, prefix = el.dataset.prefix || '', t0 = performance.now(), dur = 1600
|
||||||
|
const step = (now: number) => {
|
||||||
|
const k = Math.min(1, (now - t0) / dur), e = 1 - Math.pow(1 - k, 3)
|
||||||
|
el.textContent = prefix + Math.round(target * e) + (k === 1 && !prefix ? '+' : '')
|
||||||
|
if (k < 1) requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// one quiet fade per block
|
||||||
|
const io = new IntersectionObserver(es => es.forEach(e => {
|
||||||
|
if (!e.isIntersecting) return
|
||||||
|
e.target.classList.add('in'); io.unobserve(e.target)
|
||||||
|
if (e.target.classList.contains('facts')) e.target.querySelectorAll<HTMLElement>('[data-count]').forEach(countUp)
|
||||||
|
}), { threshold: 0.15 })
|
||||||
|
document.querySelectorAll('.rv').forEach(el => io.observe(el))
|
||||||
|
cleanups.push(() => io.disconnect())
|
||||||
|
|
||||||
|
// timeline beam
|
||||||
|
const tl = document.getElementById('tl')
|
||||||
|
if (tl) { const tio = new IntersectionObserver(es => es.forEach(e => { if (e.isIntersecting) tl.classList.add('in') }), { threshold: 0.2 }); tio.observe(tl); cleanups.push(() => tio.disconnect()) }
|
||||||
|
|
||||||
|
// hover-morph rows
|
||||||
|
const rows = [...document.querySelectorAll<HTMLElement>('[data-shape]')]
|
||||||
|
rows.forEach(el => {
|
||||||
|
const on = () => hoverIn(el.dataset.shape!, el.dataset.label || '')
|
||||||
|
el.addEventListener('pointerenter', on); el.addEventListener('pointerleave', hoverOut)
|
||||||
|
el.addEventListener('focusin', on); el.addEventListener('focusout', hoverOut)
|
||||||
|
cleanups.push(() => { el.removeEventListener('pointerenter', on); el.removeEventListener('pointerleave', hoverOut); el.removeEventListener('focusin', on); el.removeEventListener('focusout', hoverOut) })
|
||||||
|
})
|
||||||
|
if (coarse) { // touch: the row nearest the middle of the screen drives the sculpture
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
const mid = innerHeight / 2; let best: HTMLElement | null = null, bd = 1e9
|
||||||
|
for (const r of rows) { const b = r.getBoundingClientRect(); if (b.bottom < 0 || b.top > innerHeight) continue; const d = Math.abs((b.top + b.bottom) / 2 - mid); if (d < bd) { bd = d; best = r } }
|
||||||
|
if (best && bd < innerHeight * 0.3) hoverIn(best.dataset.shape!, best.dataset.label || ''); else hoverOut()
|
||||||
|
}, 250)
|
||||||
|
cleanups.push(() => clearInterval(iv))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coarse && !reduce) {
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-tilt]').forEach(el => {
|
||||||
|
const mv = (e: PointerEvent) => { const r = el.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width - 0.5, y = (e.clientY - r.top) / r.height - 0.5; el.style.transform = `perspective(900px) rotateX(${-y * 6}deg) rotateY(${x * 8}deg) translateZ(6px)` }
|
||||||
|
const lv = () => { el.style.transform = '' }
|
||||||
|
el.addEventListener('pointermove', mv); el.addEventListener('pointerleave', lv)
|
||||||
|
cleanups.push(() => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerleave', lv) })
|
||||||
|
})
|
||||||
|
document.querySelectorAll<HTMLElement>('[data-mag]').forEach(el => {
|
||||||
|
const mv = (e: PointerEvent) => { const r = el.getBoundingClientRect(); const x = e.clientX - (r.left + r.width / 2), y = e.clientY - (r.top + r.height / 2); el.style.transform = `translate(${x * 0.25}px,${y * 0.35}px)`; el.style.transition = 'transform .15s' }
|
||||||
|
const lv = () => { el.style.transition = 'transform .5s cubic-bezier(.16,1,.3,1)'; el.style.transform = '' }
|
||||||
|
el.addEventListener('pointermove', mv); el.addEventListener('pointerleave', lv)
|
||||||
|
cleanups.push(() => { el.removeEventListener('pointermove', mv); el.removeEventListener('pointerleave', lv) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// custom cursor
|
||||||
|
if (!coarse) {
|
||||||
|
const dot = document.querySelector<HTMLElement>('.cur'), ring = document.querySelector<HTMLElement>('.cur-ring')
|
||||||
|
if (dot && ring) {
|
||||||
|
let cx = innerWidth / 2, cy = innerHeight / 2, rx = cx, ry = cy, raf = 0
|
||||||
|
const mv = (e: PointerEvent) => { cx = e.clientX; cy = e.clientY }
|
||||||
|
addEventListener('pointermove', mv, { passive: true })
|
||||||
|
const loop = () => { rx += (cx - rx) * 0.18; ry += (cy - ry) * 0.18; dot.style.transform = `translate(${cx}px,${cy}px) translate(-50%,-50%)`; ring.style.transform = `translate(${rx}px,${ry}px) translate(-50%,-50%)`; raf = requestAnimationFrame(loop) }
|
||||||
|
loop()
|
||||||
|
const big = () => ring.classList.add('big'), small = () => ring.classList.remove('big')
|
||||||
|
const links = [...document.querySelectorAll('a,button')]
|
||||||
|
links.forEach(a => { a.addEventListener('pointerenter', big); a.addEventListener('pointerleave', small) })
|
||||||
|
cleanups.push(() => { cancelAnimationFrame(raf); removeEventListener('pointermove', mv); links.forEach(a => { a.removeEventListener('pointerenter', big); a.removeEventListener('pointerleave', small) }) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// smooth anchors
|
||||||
|
const anchors = [...document.querySelectorAll<HTMLAnchorElement>('nav a[href^="#"]')]
|
||||||
|
const click = (e: Event) => { const a = e.currentTarget as HTMLAnchorElement; const t = document.querySelector(a.getAttribute('href')!); if (t) { e.preventDefault(); t.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth' }) } }
|
||||||
|
anchors.forEach(a => a.addEventListener('click', click))
|
||||||
|
cleanups.push(() => anchors.forEach(a => a.removeEventListener('click', click)))
|
||||||
|
|
||||||
|
return () => cleanups.forEach(f => f())
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
+799
@@ -0,0 +1,799 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Pietro Quintavalle — Bare-metal systems and Linux infrastructure</title>
|
||||||
|
<meta name="description" content="Bare-metal deployments, server rack integration and low-level Linux systems diagnostics by Pietro Quintavalle, Ticino, Switzerland.">
|
||||||
|
<meta name="author" content="Pietro Quintavalle">
|
||||||
|
<meta name="theme-color" content="#eef0f3">
|
||||||
|
<meta property="og:title" content="Pietro Quintavalle — Bare-metal systems and Linux infrastructure">
|
||||||
|
<meta property="og:description" content="Bare-metal deployments, server rack integration and low-level Linux systems diagnostics.">
|
||||||
|
<meta property="og:site_name" content="Pietro Quintavalle">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%23141821'/%3E%3Ctext x='32' y='43' text-anchor='middle' font-family='Arial Black,Arial' font-weight='900' font-size='30' fill='%23eef0f3'%3EPQ%3C/text%3E%3C/svg%3E">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@500;700;800&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#eef0f3; --bg-2:#e3e6eb; --fg:#141821; --muted:#5b6472;
|
||||||
|
--amber:#c86a00; --ice:#2f5fd6; --line:rgba(20,24,33,.14);
|
||||||
|
--display:'Syne',"Helvetica Neue",Arial,sans-serif;
|
||||||
|
--body:'IBM Plex Sans',"Segoe UI",system-ui,sans-serif;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ --bg:#eef0f3; --bg-2:#e3e6eb; --fg:#141821; --muted:#5b6472; } }
|
||||||
|
:root[data-theme="dark"]{ --bg:#eef0f3; --bg-2:#e3e6eb; --fg:#141821; --muted:#5b6472; }
|
||||||
|
:root[data-theme="light"]{ --bg:#eef0f3; --fg:#141821; }
|
||||||
|
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html{scroll-behavior:auto}
|
||||||
|
body{background:var(--bg);color:var(--fg);font-family:var(--body);font-size:17px;line-height:1.55;overflow-x:hidden;-webkit-font-smoothing:antialiased;cursor:none}
|
||||||
|
@media (pointer:coarse){ body{cursor:auto} }
|
||||||
|
a{color:inherit;text-decoration:none}
|
||||||
|
::selection{background:var(--amber);color:#fff}
|
||||||
|
|
||||||
|
#scene{position:fixed;inset:0;z-index:0;display:block}
|
||||||
|
.grain{position:fixed;inset:-100%;z-index:1;pointer-events:none;opacity:.05;
|
||||||
|
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='300'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
animation:grain 1.2s steps(6) infinite}
|
||||||
|
@keyframes grain{0%{transform:translate(0,0)}20%{transform:translate(-3%,2%)}40%{transform:translate(2%,-4%)}60%{transform:translate(-4%,-1%)}80%{transform:translate(3%,3%)}100%{transform:translate(0,0)}}
|
||||||
|
.vignette{position:fixed;inset:0;z-index:1;pointer-events:none;background:radial-gradient(ellipse 80% 70% at 50% 45%,transparent 45%,rgba(238,240,243,.7) 100%)}
|
||||||
|
|
||||||
|
/* cursor */
|
||||||
|
.cur,.cur-ring{position:fixed;top:0;left:0;z-index:50;pointer-events:none;border-radius:50%;transform:translate(-50%,-50%);mix-blend-mode:difference}
|
||||||
|
.cur{width:6px;height:6px;background:#fff}
|
||||||
|
.cur-ring{width:34px;height:34px;border:1px solid #fff;transition:width .25s,height .25s,opacity .25s}
|
||||||
|
.cur-ring.big{width:64px;height:64px;opacity:.6}
|
||||||
|
@media (pointer:coarse){ .cur,.cur-ring{display:none} }
|
||||||
|
|
||||||
|
/* nav */
|
||||||
|
nav{position:fixed;top:0;left:0;right:0;z-index:20;display:flex;justify-content:space-between;align-items:center;padding:22px clamp(20px,4vw,56px);font-size:14px;font-weight:500;mix-blend-mode:difference}
|
||||||
|
nav .brand{font-family:var(--display);font-weight:800;letter-spacing:-.02em;font-size:17px}
|
||||||
|
nav ul{display:flex;gap:26px;list-style:none}
|
||||||
|
nav a{position:relative;padding:4px 0}
|
||||||
|
nav a::after{content:"";position:absolute;left:0;right:100%;bottom:0;height:1px;background:#fff;transition:right .3s cubic-bezier(.6,0,.2,1)}
|
||||||
|
nav a:hover::after,nav a:focus-visible::after{right:0}
|
||||||
|
@media (max-width:720px){ nav ul{display:none} }
|
||||||
|
.progress{position:fixed;top:0;left:0;height:2px;width:100%;transform-origin:0 0;transform:scaleX(0);background:var(--amber);z-index:21}
|
||||||
|
|
||||||
|
/* shape label */
|
||||||
|
.shape-label{position:fixed;left:clamp(20px,4vw,56px);bottom:26px;z-index:20;font-size:13px;color:var(--muted);display:flex;align-items:center;gap:12px;pointer-events:none}
|
||||||
|
.shape-label i{width:6px;height:6px;border-radius:50%;background:var(--amber);box-shadow:0 0 10px rgba(200,106,0,.6);animation:blink 2.4s infinite}
|
||||||
|
@keyframes blink{0%,100%{opacity:1}50%{opacity:.25}}
|
||||||
|
.shape-label span{display:inline-block;transition:opacity .4s,transform .4s}
|
||||||
|
.shape-label span.swap{opacity:0;transform:translateY(6px)}
|
||||||
|
|
||||||
|
/* layout */
|
||||||
|
main{position:relative;z-index:2}
|
||||||
|
section{min-height:100vh;padding:20vh clamp(20px,6vw,120px) 12vh;display:flex;flex-direction:column;justify-content:center}
|
||||||
|
.wrap{max-width:1180px;width:100%;margin:0 auto;position:relative}
|
||||||
|
section:not(.hero):not(.contact) .wrap::before{content:"";position:absolute;inset:-60px -80px;z-index:-1;pointer-events:none;
|
||||||
|
background:radial-gradient(ellipse 70% 60% at 38% 50%,rgba(238,240,243,.94) 35%,rgba(238,240,243,.75) 60%,rgba(238,240,243,0) 85%)}
|
||||||
|
h1,h2,h3{font-family:var(--display);letter-spacing:-.03em;line-height:.95}
|
||||||
|
h2{font-size:clamp(38px,6vw,84px);font-weight:800;margin-bottom:.5em;max-width:14ch}
|
||||||
|
h3{font-size:clamp(20px,2.2vw,28px);font-weight:700;letter-spacing:-.02em;line-height:1.15}
|
||||||
|
p.lead{font-size:clamp(18px,1.5vw,22px);max-width:56ch;color:var(--fg)}
|
||||||
|
p.dim{color:var(--muted);max-width:60ch}
|
||||||
|
|
||||||
|
/* reveal: opacity only, one quiet fade */
|
||||||
|
.rv{opacity:0;transition:opacity 1s ease}
|
||||||
|
.rv.in{opacity:1}
|
||||||
|
|
||||||
|
/* hero */
|
||||||
|
.hero{min-height:100vh;padding-top:24vh;justify-content:flex-start;pointer-events:none}
|
||||||
|
.hero .wrap>*{pointer-events:auto}
|
||||||
|
.intro{font-size:15px;color:var(--muted);margin-bottom:2.2em;max-width:44ch}
|
||||||
|
.name{font-size:clamp(56px,12.5vw,190px);font-weight:800;line-height:.86;letter-spacing:-.05em;overflow:hidden}
|
||||||
|
.name .ln{display:block;overflow:hidden;padding-bottom:.06em}
|
||||||
|
.name .ch{display:inline-block;transform:translateY(115%) rotate(6deg);opacity:0;transition:transform 1.1s cubic-bezier(.16,1,.3,1),opacity .6s;transform-origin:0 100%}
|
||||||
|
.name.on .ch{transform:none;opacity:1}
|
||||||
|
.tag{display:flex;flex-wrap:wrap;gap:16px 40px;align-items:flex-end;margin-top:6vh}
|
||||||
|
.tag p{font-size:clamp(18px,1.7vw,24px);max-width:36ch;font-weight:500}
|
||||||
|
.tag p em{font-style:normal;color:var(--amber)}
|
||||||
|
.hint{margin-top:9vh;font-size:13px;color:var(--muted);display:flex;align-items:center;gap:14px}
|
||||||
|
.hint b{display:block;width:1px;height:46px;background:linear-gradient(var(--fg),transparent);animation:drop 1.8s cubic-bezier(.6,0,.2,1) infinite;transform-origin:top}
|
||||||
|
@keyframes drop{0%{transform:scaleY(0);opacity:0}40%{opacity:1}100%{transform:scaleY(1);opacity:0}}
|
||||||
|
|
||||||
|
/* facts row */
|
||||||
|
.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:24px 40px;margin-top:9vh;border-top:1px solid var(--line);padding-top:26px;max-width:900px}
|
||||||
|
.facts div{display:flex;flex-direction:column;gap:4px}
|
||||||
|
.facts b{font-family:var(--display);font-size:clamp(30px,3vw,44px);font-weight:700;letter-spacing:-.03em;line-height:1}
|
||||||
|
.facts small{font-size:13px;color:var(--muted)}
|
||||||
|
|
||||||
|
/* qualities: ruled list, not cards */
|
||||||
|
.qual{display:grid;grid-template-columns:1fr 1fr;gap:0 60px;border-top:1px solid var(--line)}
|
||||||
|
.qual article{padding:34px 0 36px;border-bottom:1px solid var(--line)}
|
||||||
|
.qual article:nth-child(-n+2){border-top:0}
|
||||||
|
.qual h3{margin-bottom:12px;max-width:16ch}
|
||||||
|
.qual p{color:var(--muted);max-width:40ch}
|
||||||
|
@media (max-width:760px){ .qual{grid-template-columns:1fr} }
|
||||||
|
|
||||||
|
/* timeline */
|
||||||
|
.tl{position:relative;padding-left:44px;max-width:860px}
|
||||||
|
.tl::before{content:"";position:absolute;left:8px;top:0;bottom:0;width:1px;background:var(--line)}
|
||||||
|
.tl .beam{position:absolute;left:8px;top:0;width:1px;height:100%;background:linear-gradient(var(--amber),var(--ice));transform:scaleY(0);transform-origin:top;transition:transform 1.6s cubic-bezier(.6,0,.2,1)}
|
||||||
|
.tl.in .beam{transform:scaleY(1)}
|
||||||
|
.job{position:relative;padding:0 0 64px}
|
||||||
|
.job::before{content:"";position:absolute;left:-40px;top:10px;width:9px;height:9px;border-radius:50%;background:var(--bg);border:1px solid var(--fg)}
|
||||||
|
.job time{display:block;font-size:14px;color:var(--muted);margin-bottom:8px}
|
||||||
|
.job h3{margin-bottom:6px}
|
||||||
|
.job .org{font-size:15px;color:var(--amber);margin-bottom:16px;display:block}
|
||||||
|
.job ul{list-style:none;display:grid;gap:10px;max-width:60ch;color:var(--fg)}
|
||||||
|
.job li{padding-left:18px;position:relative;color:#2b3140}
|
||||||
|
.job li::before{content:"";position:absolute;left:0;top:.65em;width:8px;height:1px;background:var(--muted)}
|
||||||
|
|
||||||
|
/* oss */
|
||||||
|
.oss{display:grid;gap:0;border-top:1px solid var(--line);max-width:960px}
|
||||||
|
.oss a{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:20px 40px;align-items:start;padding:32px 0;border-bottom:1px solid var(--line);transition:padding-left .4s cubic-bezier(.16,1,.3,1)}
|
||||||
|
.oss a:hover,.oss a:focus-visible{padding-left:14px}
|
||||||
|
.oss .repo{font-size:14px;color:var(--ice);margin-bottom:8px;display:block}
|
||||||
|
.oss .what{color:var(--muted);max-width:56ch}
|
||||||
|
.oss .ref{font-family:var(--display);font-weight:700;font-size:15px;color:var(--fg);white-space:nowrap;padding-top:2px;opacity:.7}
|
||||||
|
@media (max-width:640px){ .oss a{grid-template-columns:1fr} }
|
||||||
|
|
||||||
|
/* projects: staggered, not a card grid */
|
||||||
|
.proj{display:grid;grid-template-columns:repeat(12,1fr);gap:40px 24px}
|
||||||
|
.proj article{grid-column:span 5;padding:28px 0 0;border-top:1px solid var(--line);position:relative;transform-style:preserve-3d;transition:transform .5s cubic-bezier(.16,1,.3,1)}
|
||||||
|
.proj article:nth-child(2){grid-column:7/span 6;margin-top:80px}
|
||||||
|
.proj article:nth-child(3){grid-column:2/span 5;margin-top:-30px}
|
||||||
|
.proj article:nth-child(4){grid-column:8/span 5;margin-top:20px}
|
||||||
|
.proj article::after{content:"";position:absolute;left:0;top:-1px;width:0;height:1px;background:var(--amber);transition:width .6s cubic-bezier(.16,1,.3,1)}
|
||||||
|
.proj article:hover::after{width:100%}
|
||||||
|
.proj .stack{font-size:13px;color:var(--muted);margin:14px 0 0;line-height:1.8}
|
||||||
|
.proj p{color:#2b3140;max-width:44ch;margin-top:10px}
|
||||||
|
.proj .star{color:var(--amber);font-size:14px}
|
||||||
|
@media (max-width:860px){ .proj article,.proj article:nth-child(n){grid-column:1/-1;margin-top:0} }
|
||||||
|
|
||||||
|
/* skills: two counter-rotating marquees */
|
||||||
|
.mq{overflow:hidden;white-space:nowrap;padding:18px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin-top:-1px;font-family:var(--display);font-weight:700;font-size:clamp(22px,3.2vw,44px);letter-spacing:-.02em}
|
||||||
|
.mq div{display:inline-block;animation:mq 60s linear infinite}
|
||||||
|
.mq.rev div{animation-direction:reverse;color:var(--muted)}
|
||||||
|
.mq span{padding:0 .55em}
|
||||||
|
.mq span::after{content:"/";color:var(--amber);padding-left:1.1em;font-weight:500}
|
||||||
|
@keyframes mq{to{transform:translateX(-50%)}}
|
||||||
|
.skills-note{margin-top:40px;color:var(--muted);max-width:60ch}
|
||||||
|
|
||||||
|
/* education */
|
||||||
|
.edu{display:grid;grid-template-columns:1fr 1fr;gap:40px 60px;max-width:960px}
|
||||||
|
.edu dl{display:grid;gap:22px}
|
||||||
|
.edu dt{font-weight:600}
|
||||||
|
.edu dd{color:var(--muted);font-size:15px}
|
||||||
|
.edu .langs{display:flex;flex-wrap:wrap;gap:10px 22px;font-size:15px;color:var(--muted)}
|
||||||
|
.edu .langs b{color:var(--fg);font-weight:500}
|
||||||
|
@media (max-width:760px){ .edu{grid-template-columns:1fr} }
|
||||||
|
|
||||||
|
/* contact */
|
||||||
|
.contact{min-height:100vh;justify-content:center;text-align:left}
|
||||||
|
.contact h2{font-size:clamp(44px,8.5vw,140px);max-width:none;line-height:.9}
|
||||||
|
.mail{display:inline-block;font-family:var(--display);font-weight:700;font-size:clamp(20px,2.6vw,34px);letter-spacing:-.02em;margin-top:20px;position:relative;padding:8px 0}
|
||||||
|
.mail::after{content:"";position:absolute;left:0;bottom:0;height:2px;width:100%;background:var(--amber);transform:scaleX(.18);transform-origin:left;transition:transform .5s cubic-bezier(.16,1,.3,1)}
|
||||||
|
.mail:hover::after,.mail:focus-visible::after{transform:scaleX(1)}
|
||||||
|
.links{display:flex;flex-wrap:wrap;gap:14px;margin-top:48px}
|
||||||
|
.links a{padding:14px 22px;border:1px solid var(--line);border-radius:999px;font-size:15px;font-weight:500;transition:border-color .3s,background .3s;will-change:transform}
|
||||||
|
.links a:hover,.links a:focus-visible{border-color:var(--fg);background:rgba(20,24,33,.05)}
|
||||||
|
footer{padding:40px clamp(20px,6vw,120px);font-size:13px;color:var(--muted);display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px;position:relative;z-index:2}
|
||||||
|
|
||||||
|
:focus-visible{outline:2px solid var(--amber);outline-offset:4px}
|
||||||
|
@media (prefers-reduced-motion:reduce){
|
||||||
|
.grain,.hint b,.shape-label i,.mq div{animation:none}
|
||||||
|
.name .ch{transition:none;transform:none;opacity:1}
|
||||||
|
.rv{transition:none;opacity:1}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<canvas id="scene" aria-hidden="true"></canvas>
|
||||||
|
<div class="grain" aria-hidden="true"></div>
|
||||||
|
<div class="vignette" aria-hidden="true"></div>
|
||||||
|
<div class="cur" aria-hidden="true"></div><div class="cur-ring" aria-hidden="true"></div>
|
||||||
|
<div class="progress" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<nav>
|
||||||
|
<a class="brand" href="#top">PQ</a>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#work">Work</a></li>
|
||||||
|
<li><a href="#oss">Open source</a></li>
|
||||||
|
<li><a href="#projects">Projects</a></li>
|
||||||
|
<li><a href="#contact">Contact</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="shape-label" aria-hidden="true"><i></i><span id="shapeName">Rack: four production servers, bare metal</span></div>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<div class="wrap">
|
||||||
|
<p class="intro rv">Monte Carasso, Ticino. Six years at the bench, five years on Linux, four servers in production. Looking for datacenter operations, hardware integration or Linux systems support, on-site or hybrid.</p>
|
||||||
|
<h1 class="name" id="name"><span class="ln">Pietro</span><span class="ln">Quintavalle</span></h1>
|
||||||
|
<div class="tag rv">
|
||||||
|
<p>I build infrastructure from the <em>solder joint</em> up. Component-level diagnostics, ZFS storage, zero-trust networks, and the occasional driver written from a packet dump.</p>
|
||||||
|
</div>
|
||||||
|
<div class="facts rv">
|
||||||
|
<div><b data-count="6">0</b><small>years assembling and repairing hardware</small></div>
|
||||||
|
<div><b data-count="25">0</b><small>systems built for private clients</small></div>
|
||||||
|
<div><b data-count="12">0</b><small>active users on self-hosted infrastructure</small></div>
|
||||||
|
<div><b data-count="30" data-prefix="<">0</b><small>minutes to rebuild a cluster from bare metal</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="hint rv"><b></b>Scroll. The particles follow.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="qualities">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">What you get when you hire me</h2>
|
||||||
|
<div class="qual rv">
|
||||||
|
<article>
|
||||||
|
<h3>Diagnoses at the component, not the ticket</h3>
|
||||||
|
<p>Thermal bench-testing, firmware and BIOS flashing, soldering and harness wiring. When a node fails I find the part, not the symptom.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Treats every host as disposable</h3>
|
||||||
|
<p>Ansible playbooks and Docker Compose rebuild the whole environment from a blank disk in under thirty minutes. Nothing important lives only in one place.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Zero exposed WAN ports, by default</h3>
|
||||||
|
<p>Cloudflare Tunnels, WireGuard and Tailscale meshes, SSO with 2FA, host firewalls and intrusion detection on every node. Security is part of the build, not a later phase.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Reads the protocol when there is no documentation</h3>
|
||||||
|
<p>Reverse-engineered a vendor USB HID protocol from packet captures and shipped a working Linux driver upstream. Undocumented hardware is a puzzle, not a blocker.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="work">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">Experience</h2>
|
||||||
|
<div class="tl rv" id="tl"><div class="beam"></div>
|
||||||
|
<div class="job" data-shape="cluster" data-label="DEC Energy: two nodes, one GPU, one HBA">
|
||||||
|
<time>2026 to now</time>
|
||||||
|
<h3>Infrastructure and hardware consultant</h3>
|
||||||
|
<span class="org">DEC Energy, USI startup, freelance</span>
|
||||||
|
<ul>
|
||||||
|
<li>Designed, procured and deployed an on-premise two-node Kubernetes cluster from parts to production: local LLM inference, web hosting, accounting and Gitea/GitLab source control.</li>
|
||||||
|
<li>Tuned fan curves and ran thermal bench stress tests so the nodes stay quiet in an open office under full inference load.</li>
|
||||||
|
<li>Specified and assembled the engineering team's workstations and remain on-call for support.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="job">
|
||||||
|
<time>January 2020 to now</time>
|
||||||
|
<h3>IT support technician</h3>
|
||||||
|
<span class="org">Private clients across Ticino, freelance</span>
|
||||||
|
<ul>
|
||||||
|
<li>Built, configured and repaired more than 25 desktop and laptop systems: sourcing, assembly, Windows and Linux deployment, driver debugging, ongoing maintenance.</li>
|
||||||
|
<li>Remote and on-site support for hardware failures, software conflicts and local networking faults, with repair logs kept to stop issues recurring.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="job" data-shape="homelab" data-label="Homelab as code: nine bays, twelve users, no open ports">
|
||||||
|
<time>January 2020 to now</time>
|
||||||
|
<h3>Infrastructure engineer, self-directed</h3>
|
||||||
|
<span class="org">Four bare-metal servers serving 12+ users</span>
|
||||||
|
<ul>
|
||||||
|
<li>UNRAID, Proxmox VE 8, TrueNAS, Docker and LXC running production workloads without any managed hosting.</li>
|
||||||
|
<li>Delivered a turnkey rack for a client: cabinet, chassis, structured cabling, TrueNAS with ZFS RAID 10, per-user ACLs and automated offsite snapshots with under thirty minutes of recovery time.</li>
|
||||||
|
<li>Hardened every edge: no open WAN ports, SSO and 2FA, intrusion detection, host-level firewalls.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="oss">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">Open source, close to the metal</h2>
|
||||||
|
<p class="dim rv" style="margin-bottom:40px">Upstream contributions to projects that sit between hardware and the kernel. Hover a row and the sculpture becomes the work.</p>
|
||||||
|
<div class="oss rv">
|
||||||
|
<a href="https://github.com/libratbag/libratbag/pull/1873" target="_blank" rel="noopener" data-shape="hid" data-label="HID report: the bytes that became a driver">
|
||||||
|
<div><span class="repo">libratbag/libratbag</span><h3>A complete USB HID device driver, written from a packet capture</h3><p class="what">Sniffed the vendor's raw HID reports in Wireshark, decoded the byte layout for DPI profiles, polling rates and on-board memory, then wrote a complete C driver that was merged upstream.</p></div>
|
||||||
|
<span class="ref">PR #1873</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/ValveSoftware/steam-for-linux/issues/13248" target="_blank" rel="noopener">
|
||||||
|
<div><span class="repo">ValveSoftware/steam-for-linux, Vysp3r/ProtonPlus</span><h3>Runtime installation pipeline fault, diagnosed and fixed upstream</h3><p class="what">Traced execution crashes in the Steam Linux Runtime to unwritten manifest files, documented a depot recovery workaround and contributed code to automate runtime integrity checks.</p></div>
|
||||||
|
<span class="ref">Issues #13248, #1232</span>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/Recol/DLSS-Updater/pull/283" target="_blank" rel="noopener">
|
||||||
|
<div><span class="repo">Recol/DLSS-Updater</span><h3>Multi-vendor upscaler library detection</h3><p class="what">Replaced brittle static tables with bounded manifest parsing so NVIDIA DLSS, AMD FSR and Intel XeSS libraries survive DLL upgrades; also fixed UI state desynchronisation.</p></div>
|
||||||
|
<span class="ref">PRs #283, #252, #256</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="projects">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">Selected projects</h2>
|
||||||
|
<div class="proj rv">
|
||||||
|
<article data-tilt data-shape="homelab" data-label="Homelab as code: nine bays, twelve users, no open ports">
|
||||||
|
<h3>Homelab as code</h3>
|
||||||
|
<p>Four production hosts rebuilt from bare metal by Ansible in under thirty minutes. Twelve-plus daily users, no exposed WAN ports: Cloudflare Tunnels, WireGuard, SSO with 2FA and Fail2Ban on every node.</p>
|
||||||
|
<div class="stack">Ansible, Docker Compose, UNRAID, Grafana, Prometheus</div>
|
||||||
|
</article>
|
||||||
|
<article data-tilt data-shape="nas" data-label="Client rack: the cabinet that opens this page">
|
||||||
|
<h3>Turnkey TrueNAS rack</h3>
|
||||||
|
<p>A 19-inch cabinet for a private client: patch panels, storage chassis and nine drive bays, every cable run dressed and labelled. TrueNAS on top with ZFS RAID 10, per-user ACLs and offsite snapshots for a sub-30-minute recovery target.</p>
|
||||||
|
<div class="stack">TrueNAS, ZFS, rack infrastructure, structured cabling</div>
|
||||||
|
</article>
|
||||||
|
<article data-tilt>
|
||||||
|
<h3>ObsidianSetup <span class="star">26 stars on GitHub</span></h3>
|
||||||
|
<p>Cross-platform provisioning utility for environment configuration and plugin management, adopted by other users.</p>
|
||||||
|
<div class="stack">PowerShell</div>
|
||||||
|
</article>
|
||||||
|
<article data-tilt data-shape="city" data-label="City simulator: twelve people, one Spring Boot backend">
|
||||||
|
<h3>City simulator, USI group project</h3>
|
||||||
|
<p>Coordinated a twelve-person Scrum team building a full-stack city simulation. Owned the Spring Boot backend and its integration with the web frontend.</p>
|
||||||
|
<div class="stack">Java, Spring Boot, JavaScript, HTML, CSS</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="skills">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">Tools I reach for</h2>
|
||||||
|
</div>
|
||||||
|
<div class="mq rv"><div id="mq1"></div></div>
|
||||||
|
<div class="mq rev rv"><div id="mq2"></div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<p class="skills-note rv">Also on the bench: Rust, C, C++, Python and SQL for diagnostics, and a self-directed track on local LLM deployment and quantisation on bare-metal hardware with llama.cpp and Claude Code.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="education">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">Education and languages</h2>
|
||||||
|
<div class="edu rv">
|
||||||
|
<dl>
|
||||||
|
<div><dt>BSc Economics, English stream</dt><dd>Università della Svizzera italiana, Lugano, since September 2024. Microeconomics, game theory, quantitative finance, statistics, financial modelling.</dd></div>
|
||||||
|
<div><dt>Computer science coursework, two full years</dt><dd>USI, 2022 to 2024. Algorithms and data structures, object-oriented systems in Java and C++, software architecture, discrete logic.</dd></div>
|
||||||
|
<div><dt>Maturità Cantonale and Federal VET Diploma, AFC Economics</dt><dd>Scuola Cantonale di Commercio, Bellinzona, 2018 to 2022. Maturità 4.5/6, AFC 5/6.</dd></div>
|
||||||
|
</dl>
|
||||||
|
<div>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Certifications</dt><dd>Google IT Support Professional Certificate. Google Data Analytics Professional Certificate. Microsoft Foundations of IT Systems, Networking and Data Protection.</dd></div>
|
||||||
|
<div><dt>Languages</dt><dd class="langs"><span><b>Italian</b> native</span><span><b>English</b> C2</span><span><b>German</b> B1–B2</span><span><b>Spanish</b> A1</span><span><b>Norwegian</b> A1</span></dd></div>
|
||||||
|
<div><dt>Status</dt><dd>Italian national, Swiss Permit C, born 2001.</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="contact" class="contact">
|
||||||
|
<div class="wrap">
|
||||||
|
<h2 class="rv">Need someone who can rack it, image it, and keep it up?</h2>
|
||||||
|
<a class="mail rv" href="mailto:0pietroquintavalle0@gmail.com">0pietroquintavalle0@gmail.com</a>
|
||||||
|
<div class="links rv">
|
||||||
|
<a data-mag href="tel:+41799068253">+41 79 906 82 53</a>
|
||||||
|
<a data-mag href="https://github.com/Quinta0" target="_blank" rel="noopener">GitHub</a>
|
||||||
|
<a data-mag href="https://linkedin.com/in/pietro-quintavalle" target="_blank" rel="noopener">LinkedIn</a>
|
||||||
|
<a data-mag href="https://quinta0.github.io/whoami" target="_blank" rel="noopener">whoami</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<footer><span>Pietro Quintavalle, Ticino, Switzerland</span><span>Built with Three.js, one canvas, fifteen thousand particles.</span></footer>
|
||||||
|
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const coarse = matchMedia('(pointer: coarse)').matches;
|
||||||
|
const isMobile = coarse || innerWidth < 760;
|
||||||
|
|
||||||
|
/* ---------- Three.js particle sculpture ---------- */
|
||||||
|
const canvas = document.getElementById('scene');
|
||||||
|
let renderer;
|
||||||
|
try { renderer = new THREE.WebGLRenderer({canvas, antialias:false, alpha:true, powerPreference:'high-performance'}); }
|
||||||
|
catch(e){ canvas.style.display='none'; }
|
||||||
|
|
||||||
|
const COUNT = isMobile ? 6000 : 15000;
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(42, innerWidth/innerHeight, 0.1, 100);
|
||||||
|
camera.position.set(0, 0, 18);
|
||||||
|
const group = new THREE.Group();
|
||||||
|
scene.add(group);
|
||||||
|
|
||||||
|
const rnd = (a=1,b=0)=>Math.random()*(a-b)+b;
|
||||||
|
|
||||||
|
/* Shape 0 — a server rack: stacked chassis outlines, drive bays, cable loom */
|
||||||
|
function shapeRack(n){
|
||||||
|
const a = new Float32Array(n*3);
|
||||||
|
const W=4.6, D=3.2, units=9, H=0.72, y0=-(units*H)/2;
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
let x,y,z; const r=Math.random();
|
||||||
|
if(r<0.42){ // horizontal chassis edges
|
||||||
|
const u=Math.floor(rnd(units+1)), edge=Math.floor(rnd(4)), t=rnd(1);
|
||||||
|
y=y0+u*H;
|
||||||
|
if(edge===0){x=-W/2+t*W;z=D/2}else if(edge===1){x=-W/2+t*W;z=-D/2}else if(edge===2){x=W/2;z=-D/2+t*D}else{x=-W/2;z=-D/2+t*D}
|
||||||
|
} else if(r<0.55){ // vertical rails
|
||||||
|
const c=Math.floor(rnd(4)); x=(c%2?1:-1)*W/2; z=(c<2?1:-1)*D/2; y=y0+rnd(units*H);
|
||||||
|
} else if(r<0.88){ // front face drive bays, dense
|
||||||
|
x=rnd(W/2-0.15,-W/2+0.15); const u=Math.floor(rnd(units)); const bay=Math.floor(rnd(3));
|
||||||
|
y=y0+u*H+0.12+bay*0.2+rnd(0.06); z=D/2+0.02;
|
||||||
|
} else { // rear cable loom drooping
|
||||||
|
const t=rnd(1); x=rnd(0.6,-0.6)+Math.sin(t*9+i)*0.25; y=y0+t*units*H; z=-D/2-0.35-Math.sin(t*3.1)*0.3;
|
||||||
|
}
|
||||||
|
a[i*3]=x;a[i*3+1]=y;a[i*3+2]=z;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Shape 1 — torus knot: the mesh network */
|
||||||
|
function shapeKnot(n){
|
||||||
|
const a=new Float32Array(n*3), p=2,q=3, R=3.0, r=1.35, tube=0.55;
|
||||||
|
const P=t=>{const c=R+r*Math.cos(q*t);return [c*Math.cos(p*t),c*Math.sin(p*t),r*Math.sin(q*t)];};
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
const t=rnd(Math.PI*2), c=P(t), c2=P(t+0.001);
|
||||||
|
let tx=c2[0]-c[0],ty=c2[1]-c[1],tz=c2[2]-c[2]; const tl=Math.hypot(tx,ty,tz); tx/=tl;ty/=tl;tz/=tl;
|
||||||
|
// normal ~ toward the ring centre, then binormal = T x N
|
||||||
|
let nx=-c[0],ny=-c[1],nz=0; const dn=tx*nx+ty*ny; nx-=tx*dn;ny-=ty*dn;nz-=tz*dn; const nl=Math.hypot(nx,ny,nz)||1; nx/=nl;ny/=nl;nz/=nl;
|
||||||
|
const bx=ty*nz-tz*ny, by=tz*nx-tx*nz, bz=tx*ny-ty*nx;
|
||||||
|
const ang=rnd(Math.PI*2), rad=Math.random()<0.7?tube:tube*Math.sqrt(Math.random());
|
||||||
|
const ca=Math.cos(ang)*rad, sa=Math.sin(ang)*rad;
|
||||||
|
a[i*3]=c[0]+nx*ca+bx*sa; a[i*3+1]=c[1]+ny*ca+by*sa; a[i*3+2]=c[2]+nz*ca+bz*sa;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Shape 2 — globe with latitude bands + a defensive shell: the zero-trust perimeter */
|
||||||
|
function shapeGlobe(n){
|
||||||
|
const a=new Float32Array(n*3);
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
const r=Math.random();
|
||||||
|
let R=4.2, theta, phi;
|
||||||
|
if(r<0.6){ // lat/long grid
|
||||||
|
const useLat=Math.random()<0.5;
|
||||||
|
if(useLat){ phi=Math.acos(1-2*(Math.floor(rnd(13))/12)); theta=rnd(Math.PI*2); }
|
||||||
|
else { theta=Math.floor(rnd(24))*(Math.PI/12); phi=Math.acos(1-2*Math.random()); }
|
||||||
|
} else if(r<0.85){ phi=Math.acos(1-2*Math.random()); theta=rnd(Math.PI*2); R=4.2+rnd(0.05); }
|
||||||
|
else { phi=Math.acos(1-2*Math.random()); theta=rnd(Math.PI*2); R=5.6+rnd(0.15); } // outer shell
|
||||||
|
a[i*3]=R*Math.sin(phi)*Math.cos(theta); a[i*3+1]=R*Math.cos(phi); a[i*3+2]=R*Math.sin(phi)*Math.sin(theta);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Canvas sampler: anything drawn white on black becomes particles */
|
||||||
|
function sampleCanvas(n,w,h,draw,S,zSpread){
|
||||||
|
const c=document.createElement('canvas'); c.width=w; c.height=h;
|
||||||
|
const ctx=c.getContext('2d'); ctx.fillStyle='#000'; ctx.fillRect(0,0,w,h); ctx.fillStyle='#fff'; ctx.strokeStyle='#fff';
|
||||||
|
draw(ctx);
|
||||||
|
const img=ctx.getImageData(0,0,w,h).data, pts=[];
|
||||||
|
for(let y=0;y<h;y+=2)for(let x=0;x<w;x+=2){ if(img[(y*w+x)*4]>128) pts.push(x,y); }
|
||||||
|
const a=new Float32Array(n*3);
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
const k=Math.floor(rnd(pts.length/2))*2;
|
||||||
|
a[i*3]=(pts[k]-w/2)*S+rnd(0.04,-0.04); a[i*3+1]=-(pts[k+1]-h/2)*S+rnd(0.04,-0.04);
|
||||||
|
a[i*3+2]=rnd(zSpread,-zSpread)*(Math.random()<0.15?1:0.15);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
/* Shape 3 — the initials */
|
||||||
|
function shapeText(n){
|
||||||
|
return sampleCanvas(n,720,360,ctx=>{ ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.font='800 300px Syne, "Helvetica Neue", Arial, sans-serif'; ctx.fillText('PQ',360,194); },0.0215,0.9);
|
||||||
|
}
|
||||||
|
/* Primitive builder for the project dioramas: boxes as edges or solid fill, plus lines */
|
||||||
|
function fromPrims(n,prims,scale){
|
||||||
|
const tot=prims.reduce((t,p)=>t+p.w,0), a=new Float32Array(n*3);
|
||||||
|
for(let i=0;i<n;i++){
|
||||||
|
let r=Math.random()*tot, pr=prims[0]; for(const q of prims){ pr=q; if(r<q.w)break; r-=q.w; }
|
||||||
|
let x,y,z;
|
||||||
|
if(pr.t==='line'){ const u=rnd(1); x=pr.a[0]+(pr.b[0]-pr.a[0])*u; y=pr.a[1]+(pr.b[1]-pr.a[1])*u; z=pr.a[2]+(pr.b[2]-pr.a[2])*u; }
|
||||||
|
else {
|
||||||
|
const [cx,cy,cz]=pr.c,[w,h,d]=pr.s;
|
||||||
|
if(pr.t==='edges'){ const e=Math.floor(rnd(12)), u=rnd(1)-0.5, s1=(e&1)?0.5:-0.5, s2=(e&2)?0.5:-0.5;
|
||||||
|
if(e<4){x=u*w;y=s1*h;z=s2*d} else if(e<8){x=s1*w;y=u*h;z=s2*d} else {x=s1*w;y=s2*h;z=u*d} }
|
||||||
|
else { x=(rnd(1)-0.5)*w; y=(rnd(1)-0.5)*h; z=(rnd(1)-0.5)*d; }
|
||||||
|
x+=cx;y+=cy;z+=cz;
|
||||||
|
}
|
||||||
|
a[i*3]=x*scale;a[i*3+1]=y*scale;a[i*3+2]=z*scale;
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
const hoverShapes={
|
||||||
|
homelab(n){ // the homelab chassis: nine bays, copper strip, grille top
|
||||||
|
const pr=[{t:'edges',c:[0,0,0],s:[2.3,2.4,2.6],w:3},{t:'fill',c:[0,1.16,0],s:[2.15,0.01,2.45],w:0.9},{t:'fill',c:[0,0.25,1.32],s:[2.3,0.22,0.06],w:0.9}];
|
||||||
|
const bw=0.17,g=0.02,x0=-(9*bw+8*g)/2+bw/2;
|
||||||
|
for(let i=0;i<9;i++){ const x=x0+i*(bw+g); pr.push({t:'edges',c:[x,-0.43,1.3],s:[bw,1.05,0.18],w:0.35}); pr.push({t:'fill',c:[x,-0.43,1.12],s:[bw-0.04,0.9,0.3],w:0.32}); }
|
||||||
|
return fromPrims(n,pr,2.1);
|
||||||
|
},
|
||||||
|
cluster(n){ // DEC Energy: two boards, GPU on one, HBA on the other, bridged
|
||||||
|
const pr=[];
|
||||||
|
for(const bx of [-0.95,0.95]){ pr.push({t:'fill',c:[bx,0,0],s:[1.5,0.08,1.8],w:0.7},{t:'edges',c:[bx,0,0],s:[1.5,0.08,1.8],w:1.6},{t:'fill',c:[bx,0.1,-0.25],s:[0.45,0.12,0.45],w:0.5}); }
|
||||||
|
pr.push({t:'fill',c:[-0.45,0.25,0.1],s:[0.12,0.42,1.2],w:1.0},{t:'fill',c:[0.45,0.22,0.1],s:[0.12,0.35,1.0],w:0.8});
|
||||||
|
pr.push({t:'line',a:[-0.5,0.12,-0.25],b:[0.5,0.12,-0.25],w:0.5});
|
||||||
|
for(const bx of [-0.95,0.95]) for(let k=0;k<3;k++) pr.push({t:'line',a:[bx-0.55,0.05,0.5+k*0.2],b:[bx+0.55,0.05,0.5+k*0.2],w:0.12});
|
||||||
|
return fromPrims(n,pr,2.4);
|
||||||
|
},
|
||||||
|
hosts(n){ // four hosts on the floor, one hub, one tunnel out
|
||||||
|
const pr=[]; const hosts=[[-1.3,-1.2,-1.0],[1.3,-1.2,-1.0],[-1.3,-1.2,1.0],[1.3,-1.2,1.0]];
|
||||||
|
for(const h of hosts){ pr.push({t:'edges',c:h,s:[1.7,0.5,1.4],w:1},{t:'fill',c:[h[0],h[1],h[2]+0.68],s:[1.5,0.32,0.05],w:0.45},{t:'line',a:[h[0],h[1]+0.25,h[2]],b:[0,0.6,0],w:0.35}); }
|
||||||
|
pr.push({t:'fill',c:[0,0.6,0],s:[0.45,0.45,0.45],w:0.6},{t:'line',a:[0,0.6,0],b:[0,2.6,0],w:0.5},{t:'edges',c:[0,2.9,0],s:[1.2,0.5,0.9],w:0.5});
|
||||||
|
return fromPrims(n,pr,1.55);
|
||||||
|
},
|
||||||
|
city(n){ // the simulated city: a grid of blocks
|
||||||
|
const pr=[{t:'edges',c:[0,-0.05,0],s:[9.2,0.02,9.2],w:1.2}]; let seed=7; const rr=()=>{seed=(seed*16807)%2147483647;return seed/2147483647;};
|
||||||
|
for(let i=0;i<8;i++)for(let j=0;j<8;j++){ const hgt=0.3+rr()*rr()*3.2, x=-3.85+i*1.1, z=-3.85+j*1.1; pr.push({t:'edges',c:[x,hgt/2,z],s:[0.8,hgt,0.8],w:0.35+hgt*0.15}); if(rr()<0.35)pr.push({t:'fill',c:[x,hgt/2,z],s:[0.78,hgt,0.78],w:0.08}); }
|
||||||
|
return fromPrims(n,pr,0.72);
|
||||||
|
},
|
||||||
|
hid(n){ // the libratbag report: header, command, profile, DPI, then upstream
|
||||||
|
return sampleCanvas(n,1000,420,ctx=>{
|
||||||
|
ctx.lineWidth=5; ctx.beginPath(); ctx.moveTo(40,40); ctx.lineTo(960,40); ctx.stroke();
|
||||||
|
ctx.setLineDash([8,10]); ctx.beginPath(); ctx.moveTo(110,40); ctx.lineTo(160,130); ctx.moveTo(420,40); ctx.lineTo(380,130); ctx.stroke(); ctx.setLineDash([]);
|
||||||
|
const boxes=[[60,120,'0x08'],[180,120,'0x14'],[300,140,'0x01 02'],[440,190,'0x0640'],[630,300,'00 00 …']];
|
||||||
|
ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.font='700 42px Syne, Arial, sans-serif';
|
||||||
|
for(const [x,w,t] of boxes){ ctx.lineWidth=(t==='0x0640')?10:5; ctx.strokeRect(x,130,w,110); ctx.fillText(t,x+w/2,185); }
|
||||||
|
ctx.lineWidth=5; ctx.beginPath(); ctx.moveTo(535,240); ctx.lineTo(535,300); ctx.stroke();
|
||||||
|
ctx.fillRect(360,300,350,70); ctx.fillStyle='#000'; ctx.font='700 30px Syne, Arial, sans-serif'; ctx.fillText('MERGED UPSTREAM',535,336);
|
||||||
|
ctx.fillStyle='#fff'; ctx.font='500 26px Syne, Arial, sans-serif'; ctx.textAlign='left';
|
||||||
|
ctx.fillText('REPORT',60,275); ctx.fillText('CMD',180,275); ctx.fillText('PROFILE 2',300,275); ctx.fillText('1600 DPI',440,275);
|
||||||
|
},0.0142,0.5);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
hoverShapes.nas=shapeRack; // the client rack is the same one that opens the page
|
||||||
|
const hoverFlat={hid:true};
|
||||||
|
const shapeCache={};
|
||||||
|
function getHoverShape(k){ return shapeCache[k]||(shapeCache[k]=hoverShapes[k](COUNT)); }
|
||||||
|
|
||||||
|
const noiseGLSL = `
|
||||||
|
vec3 mod289(vec3 x){return x-floor(x*(1.0/289.0))*289.0;}
|
||||||
|
vec4 mod289(vec4 x){return x-floor(x*(1.0/289.0))*289.0;}
|
||||||
|
vec4 permute(vec4 x){return mod289(((x*34.0)+1.0)*x);}
|
||||||
|
vec4 taylorInvSqrt(vec4 r){return 1.79284291400159-0.85373472095314*r;}
|
||||||
|
float snoise(vec3 v){
|
||||||
|
const vec2 C=vec2(1.0/6.0,1.0/3.0); const vec4 D=vec4(0.0,0.5,1.0,2.0);
|
||||||
|
vec3 i=floor(v+dot(v,C.yyy)); vec3 x0=v-i+dot(i,C.xxx);
|
||||||
|
vec3 g=step(x0.yzx,x0.xyz); vec3 l=1.0-g; vec3 i1=min(g.xyz,l.zxy); vec3 i2=max(g.xyz,l.zxy);
|
||||||
|
vec3 x1=x0-i1+C.xxx; vec3 x2=x0-i2+C.yyy; vec3 x3=x0-D.yyy;
|
||||||
|
i=mod289(i);
|
||||||
|
vec4 p=permute(permute(permute(i.z+vec4(0.0,i1.z,i2.z,1.0))+i.y+vec4(0.0,i1.y,i2.y,1.0))+i.x+vec4(0.0,i1.x,i2.x,1.0));
|
||||||
|
float n_=0.142857142857; vec3 ns=n_*D.wyz-D.xzx;
|
||||||
|
vec4 j=p-49.0*floor(p*ns.z*ns.z); vec4 x_=floor(j*ns.z); vec4 y_=floor(j-7.0*x_);
|
||||||
|
vec4 x=x_*ns.x+ns.yyyy; vec4 y=y_*ns.x+ns.yyyy; vec4 h=1.0-abs(x)-abs(y);
|
||||||
|
vec4 b0=vec4(x.xy,y.xy); vec4 b1=vec4(x.zw,y.zw);
|
||||||
|
vec4 s0=floor(b0)*2.0+1.0; vec4 s1=floor(b1)*2.0+1.0; vec4 sh=-step(h,vec4(0.0));
|
||||||
|
vec4 a0=b0.xzyw+s0.xzyw*sh.xxyy; vec4 a1=b1.xzyw+s1.xzyw*sh.zzww;
|
||||||
|
vec3 p0=vec3(a0.xy,h.x); vec3 p1=vec3(a0.zw,h.y); vec3 p2=vec3(a1.xy,h.z); vec3 p3=vec3(a1.zw,h.w);
|
||||||
|
vec4 norm=taylorInvSqrt(vec4(dot(p0,p0),dot(p1,p1),dot(p2,p2),dot(p3,p3)));
|
||||||
|
p0*=norm.x;p1*=norm.y;p2*=norm.z;p3*=norm.w;
|
||||||
|
vec4 m=max(0.6-vec4(dot(x0,x0),dot(x1,x1),dot(x2,x2),dot(x3,x3)),0.0); m=m*m;
|
||||||
|
return 42.0*dot(m*m,vec4(dot(p0,x0),dot(p1,x1),dot(p2,x2),dot(p3,x3)));
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const vert = noiseGLSL + `
|
||||||
|
attribute vec3 aP0; attribute vec3 aP1; attribute vec3 aP2; attribute vec3 aP3; attribute vec3 aPH; attribute vec3 aPH2; attribute float aRand;
|
||||||
|
uniform float uProgress; uniform float uTime; uniform float uPR; uniform vec3 uMouse; uniform float uMouseOn; uniform float uIntro; uniform float uDim; uniform float uHover; uniform float uSwap;
|
||||||
|
varying float vA; varying float vHeat;
|
||||||
|
void main(){
|
||||||
|
float p=clamp(uProgress,0.0,3.0);
|
||||||
|
float s01=smoothstep(0.0,1.0,p), s12=smoothstep(1.0,2.0,p), s23=smoothstep(2.0,3.0,p);
|
||||||
|
vec3 pos = aP0*(1.0-s01) + aP1*(s01-s12) + aP2*(s12-s23) + aP3*s23;
|
||||||
|
float f=fract(p); float tr = (p>=3.0)?0.0:f*(1.0-f)*4.0; // 0 at rest, 1 mid-morph
|
||||||
|
// hover: the sculpture becomes the project under the cursor
|
||||||
|
vec3 hp = mix(aPH2, aPH, uSwap);
|
||||||
|
pos = mix(pos, hp, uHover);
|
||||||
|
tr = tr*(1.0-uHover) + max(uHover*(1.0-uHover)*4.0, uSwap*(1.0-uSwap)*4.0)*0.7;
|
||||||
|
vec3 nv = vec3(snoise(pos*0.45+uTime*0.12), snoise(pos*0.45+vec3(13.7)+uTime*0.12), snoise(pos*0.45+vec3(71.2)+uTime*0.12));
|
||||||
|
pos += nv*(0.07*(1.0-uHover*0.85) + tr*(2.2+aRand*2.0));
|
||||||
|
// intro: particles fall in from a scattered cloud
|
||||||
|
vec3 spread = (aRand-0.5)*vec3(30.0,20.0,30.0) + nv*6.0;
|
||||||
|
pos = mix(spread, pos, uIntro);
|
||||||
|
// mouse repulsion
|
||||||
|
vec3 d = pos - uMouse; float dist=length(d);
|
||||||
|
float rep = smoothstep(3.2,0.0,dist)*uMouseOn;
|
||||||
|
pos += (d/max(dist,0.001))*rep*1.6;
|
||||||
|
vec4 mv = modelViewMatrix*vec4(pos,1.0);
|
||||||
|
gl_Position = projectionMatrix*mv;
|
||||||
|
float size = (0.8+aRand*1.4)*uPR*(1.0+tr*1.0+rep*1.4);
|
||||||
|
gl_PointSize = min(size*(200.0/-mv.z), 9.0*uPR);
|
||||||
|
vA = (0.35+0.55*aRand)*uIntro*uDim*(1.0-tr*0.45);
|
||||||
|
vHeat = clamp(tr*0.9+rep+aRand*0.25,0.0,1.0);
|
||||||
|
}`;
|
||||||
|
const frag = `
|
||||||
|
uniform vec3 uC1; uniform vec3 uC2; varying float vA; varying float vHeat;
|
||||||
|
void main(){
|
||||||
|
vec2 uv=gl_PointCoord-0.5; float d=length(uv); if(d>0.5) discard;
|
||||||
|
float a=smoothstep(0.5,0.12,d);
|
||||||
|
vec3 c=mix(uC1,uC2,vHeat);
|
||||||
|
gl_FragColor=vec4(c, a*vA);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
let uniforms, points;
|
||||||
|
function buildParticles(){
|
||||||
|
const g=new THREE.BufferGeometry();
|
||||||
|
const rand=new Float32Array(COUNT); for(let i=0;i<COUNT;i++)rand[i]=Math.random();
|
||||||
|
g.setAttribute('position', new THREE.BufferAttribute(shapeRack(COUNT),3));
|
||||||
|
g.setAttribute('aP0', new THREE.BufferAttribute(shapeRack(COUNT),3));
|
||||||
|
g.setAttribute('aP1', new THREE.BufferAttribute(shapeKnot(COUNT),3));
|
||||||
|
g.setAttribute('aP2', new THREE.BufferAttribute(shapeGlobe(COUNT),3));
|
||||||
|
g.setAttribute('aP3', new THREE.BufferAttribute(shapeText(COUNT),3));
|
||||||
|
g.setAttribute('aPH', new THREE.BufferAttribute(new Float32Array(COUNT*3),3));
|
||||||
|
g.setAttribute('aPH2', new THREE.BufferAttribute(new Float32Array(COUNT*3),3));
|
||||||
|
g.setAttribute('aRand', new THREE.BufferAttribute(rand,1));
|
||||||
|
uniforms={
|
||||||
|
uProgress:{value:0}, uTime:{value:0}, uPR:{value:Math.min(devicePixelRatio,2)},
|
||||||
|
uMouse:{value:new THREE.Vector3(999,999,0)}, uMouseOn:{value:0}, uIntro:{value:reduce?1:0}, uDim:{value:1}, uHover:{value:0}, uSwap:{value:1},
|
||||||
|
uC1:{value:new THREE.Color('#1f3d9e')}, uC2:{value:new THREE.Color('#d9741a')}
|
||||||
|
};
|
||||||
|
const m=new THREE.ShaderMaterial({uniforms,vertexShader:vert,fragmentShader:frag,transparent:true,depthWrite:false,blending:THREE.NormalBlending});
|
||||||
|
points=new THREE.Points(g,m);
|
||||||
|
group.add(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize(){
|
||||||
|
if(!renderer)return;
|
||||||
|
renderer.setPixelRatio(Math.min(devicePixelRatio,2));
|
||||||
|
renderer.setSize(innerWidth,innerHeight,false);
|
||||||
|
camera.aspect=innerWidth/innerHeight; camera.updateProjectionMatrix();
|
||||||
|
camera.position.z = innerWidth<760 ? 26 : 18;
|
||||||
|
}
|
||||||
|
addEventListener('resize',()=>{resize();updateScroll();});
|
||||||
|
|
||||||
|
/* scroll + mouse state */
|
||||||
|
let hoverT=0, hoverCur=0, swapCur=1, hoverKey=null, hoverLabel=null;
|
||||||
|
let progT=0, progCur=0, keyEls=null;
|
||||||
|
let scrollT=0, scrollCur=0, mx=0,my=0, mxCur=0,myCur=0, mouseOn=0, mouseOnCur=0;
|
||||||
|
function updateScroll(){
|
||||||
|
const max=document.documentElement.scrollHeight-innerHeight; scrollT=max>0?scrollY/max:0;
|
||||||
|
if(!keyEls) keyEls=['.hero','#work','#skills','#contact'].map(q=>document.querySelector(q));
|
||||||
|
const c=keyEls.map(el=>el.offsetTop+el.offsetHeight/2-innerHeight/2), y=scrollY;
|
||||||
|
let v=3;
|
||||||
|
if(y<=c[0]) v=0; else for(let i=0;i<3;i++){ if(y<c[i+1]){ const f=(y-c[i])/Math.max(1,c[i+1]-c[i]); v=i+THREE.MathUtils.smoothstep(f,0.45,1); break; } }
|
||||||
|
progT=v/3;
|
||||||
|
}
|
||||||
|
addEventListener('scroll',updateScroll,{passive:true});
|
||||||
|
addEventListener('pointermove',e=>{ mx=(e.clientX/innerWidth)*2-1; my=-(e.clientY/innerHeight)*2+1; mouseOn=1; },{passive:true});
|
||||||
|
addEventListener('pointerleave',()=>{mouseOn=0});
|
||||||
|
document.addEventListener('mouseleave',()=>{mouseOn=0});
|
||||||
|
|
||||||
|
const plane=new THREE.Plane(new THREE.Vector3(0,0,1),0), ray=new THREE.Raycaster(), hit=new THREE.Vector3(), ndc=new THREE.Vector2();
|
||||||
|
const clock=new THREE.Clock();
|
||||||
|
let introT=0;
|
||||||
|
|
||||||
|
function frame(){
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
if(!renderer||!points)return;
|
||||||
|
const dt=Math.min(clock.getDelta(),0.05);
|
||||||
|
const t=clock.elapsedTime;
|
||||||
|
scrollCur+= (scrollT-scrollCur)*0.06; progCur+=(progT-progCur)*0.07;
|
||||||
|
mxCur+=(mx-mxCur)*0.08; myCur+=(my-myCur)*0.08;
|
||||||
|
mouseOnCur+=(mouseOn-mouseOnCur)*0.1;
|
||||||
|
if(!reduce){ introT=Math.min(1,introT+dt*0.55); uniforms.uIntro.value = 1-Math.pow(1-introT,3); }
|
||||||
|
|
||||||
|
const prog=progCur*3.0;
|
||||||
|
uniforms.uProgress.value=prog;
|
||||||
|
uniforms.uTime.value=t;
|
||||||
|
|
||||||
|
// sculpture drifts as you scroll; camera parallax from cursor
|
||||||
|
group.rotation.y = scrollCur*Math.PI*1.35 + t*0.03;
|
||||||
|
group.rotation.x = Math.sin(scrollCur*Math.PI)*0.35;
|
||||||
|
// last shape (initials) faces the camera flat
|
||||||
|
const face = THREE.MathUtils.smoothstep(prog,2.55,3.0);
|
||||||
|
group.rotation.y = THREE.MathUtils.lerp(group.rotation.y, Math.round(group.rotation.y/(Math.PI*2))*Math.PI*2, face);
|
||||||
|
group.rotation.x = THREE.MathUtils.lerp(group.rotation.x, 0, face);
|
||||||
|
// hover: settle into a three-quarter view (or flat for the packet diagram)
|
||||||
|
hoverCur += (hoverT-hoverCur)*0.06; swapCur += (1-swapCur)*0.08;
|
||||||
|
const hEase = hoverCur*hoverCur*(3-2*hoverCur);
|
||||||
|
uniforms.uHover.value = hEase; uniforms.uSwap.value = swapCur;
|
||||||
|
if(hoverCur>0.001){
|
||||||
|
const flat = hoverKey && hoverFlat[hoverKey];
|
||||||
|
const ty = (flat?0:0.62) + t*0.08, tx = flat?0:0.3;
|
||||||
|
const near = ty + Math.round((group.rotation.y-ty)/(Math.PI*2))*Math.PI*2;
|
||||||
|
group.rotation.y = THREE.MathUtils.lerp(group.rotation.y, near, hEase);
|
||||||
|
group.rotation.x = THREE.MathUtils.lerp(group.rotation.x, tx, hEase);
|
||||||
|
}
|
||||||
|
// rack sits slightly right of the name on wide screens
|
||||||
|
const mid = THREE.MathUtils.smoothstep(prog,0.15,0.6)*(1-THREE.MathUtils.smoothstep(prog,2.5,2.95));
|
||||||
|
const offX = innerWidth<760 ? 0 : THREE.MathUtils.lerp(5.5, 4.5, THREE.MathUtils.smoothstep(prog,0,0.8))*(1-THREE.MathUtils.smoothstep(prog,2.5,2.95));
|
||||||
|
const hx = innerWidth<760 ? 0 : 3.6, hz = innerWidth<760 ? 2 : 5.5;
|
||||||
|
group.position.x += (THREE.MathUtils.lerp(offX,hx,hEase)-group.position.x)*0.05;
|
||||||
|
group.position.z += (hz*hEase-group.position.z)*0.05;
|
||||||
|
uniforms.uDim.value = THREE.MathUtils.lerp(1-mid*0.55, 1, hEase);
|
||||||
|
|
||||||
|
camera.position.x += (mxCur*0.9-camera.position.x)*0.05;
|
||||||
|
camera.position.y += (myCur*0.6-camera.position.y)*0.05;
|
||||||
|
camera.lookAt(0,0,0);
|
||||||
|
|
||||||
|
// mouse -> world on z=0 plane -> group local
|
||||||
|
ndc.set(mxCur,myCur); ray.setFromCamera(ndc,camera);
|
||||||
|
if(ray.ray.intersectPlane(plane,hit)){ group.worldToLocal(hit); uniforms.uMouse.value.copy(hit); }
|
||||||
|
uniforms.uMouseOn.value=coarse?0:mouseOnCur;
|
||||||
|
|
||||||
|
renderer.render(scene,camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* shape label */
|
||||||
|
const names=['Rack: four production servers, bare metal','Mesh: zero exposed ports, every node peered','Perimeter: SSO, 2FA, host firewalls, intrusion detection','Contact: the whole thing reassembles on request'];
|
||||||
|
const labelEl=document.getElementById('shapeName'); let labelIdx=0;
|
||||||
|
function setLabel(txt){ if(labelEl.textContent===txt)return; labelEl.classList.add('swap'); setTimeout(()=>{labelEl.textContent=txt;labelEl.classList.remove('swap')},400); }
|
||||||
|
setInterval(()=>{ if(hoverT>0){ setLabel(hoverLabel); return; } const i=Math.min(3,Math.round(progCur*3)); if(i!==labelIdx||labelEl.textContent!==names[i]){ labelIdx=i; setLabel(names[i]);} },200);
|
||||||
|
|
||||||
|
/* progress bar */
|
||||||
|
const bar=document.querySelector('.progress');
|
||||||
|
(function pb(){ bar.style.transform='scaleX('+scrollCur.toFixed(4)+')'; requestAnimationFrame(pb); })();
|
||||||
|
|
||||||
|
/* ---------- DOM motion ---------- */
|
||||||
|
// split hero name into characters
|
||||||
|
const name=document.getElementById('name');
|
||||||
|
name.querySelectorAll('.ln').forEach((ln,li)=>{
|
||||||
|
const txt=ln.textContent; ln.textContent='';
|
||||||
|
[...txt].forEach((ch,i)=>{ const s=document.createElement('span'); s.className='ch'; s.textContent=ch; s.style.transitionDelay=(0.25+li*0.35+i*0.04)+'s'; ln.appendChild(s); });
|
||||||
|
});
|
||||||
|
name.setAttribute('aria-label','Pietro Quintavalle');
|
||||||
|
|
||||||
|
// counters
|
||||||
|
function countUp(el){
|
||||||
|
const target=+el.dataset.count, prefix=el.dataset.prefix||''; const t0=performance.now(), dur=1600;
|
||||||
|
(function step(now){ const k=Math.min(1,(now-t0)/dur); const e=1-Math.pow(1-k,3); el.textContent=prefix+Math.round(target*e)+(k===1&&!prefix?'+':''); if(k<1)requestAnimationFrame(step); })(t0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reveal (opacity only)
|
||||||
|
const io=new IntersectionObserver(es=>es.forEach(e=>{ if(e.isIntersecting){ e.target.classList.add('in'); io.unobserve(e.target);
|
||||||
|
if(e.target.classList.contains('facts')) e.target.querySelectorAll('[data-count]').forEach(countUp); } }),{threshold:0.15});
|
||||||
|
document.querySelectorAll('.rv').forEach(el=>io.observe(el));
|
||||||
|
|
||||||
|
// marquees
|
||||||
|
const rowA=['Proxmox VE','TrueNAS','ZFS','UNRAID','Kubernetes','Docker','LXC','KVM','Ansible','GitHub Actions','Bash','PowerShell','Debian','Arch','NixOS','Windows Server','Active Directory'];
|
||||||
|
const rowB=['WireGuard','Tailscale','Cloudflare Tunnels','UniFi','VLANs','iptables','Fail2Ban','AdGuard Home','Grafana','Prometheus','Uptime Kuma','Spring Boot','TypeScript','llama.cpp','Rust','C'];
|
||||||
|
function fill(id,arr){ const el=document.getElementById(id); const h=arr.map(s=>'<span>'+s+'</span>').join(''); el.innerHTML=h+h; }
|
||||||
|
fill('mq1',rowA); fill('mq2',rowB);
|
||||||
|
|
||||||
|
// hover-morph: rows that carry a shape
|
||||||
|
function hoverIn(el){
|
||||||
|
const k=el.dataset.shape; if(!points||!hoverShapes[k])return;
|
||||||
|
if(hoverKey!==k){
|
||||||
|
const ph=points.geometry.getAttribute('aPH'), ph2=points.geometry.getAttribute('aPH2');
|
||||||
|
if(hoverKey){ ph2.array.set(ph.array); ph2.needsUpdate=true; swapCur=0; } else { ph2.array.set(getHoverShape(k)); ph2.needsUpdate=true; swapCur=1; }
|
||||||
|
ph.array.set(getHoverShape(k)); ph.needsUpdate=true; hoverKey=k;
|
||||||
|
}
|
||||||
|
hoverLabel=el.dataset.label; hoverT=1;
|
||||||
|
}
|
||||||
|
function hoverOut(){ hoverT=0; }
|
||||||
|
document.querySelectorAll('[data-shape]').forEach(el=>{
|
||||||
|
el.addEventListener('pointerenter',()=>hoverIn(el));
|
||||||
|
el.addEventListener('pointerleave',hoverOut);
|
||||||
|
el.addEventListener('focusin',()=>hoverIn(el)); el.addEventListener('focusout',hoverOut);
|
||||||
|
});
|
||||||
|
if(coarse){ // touch: the row nearest the middle of the screen drives the sculpture
|
||||||
|
const rows=[...document.querySelectorAll('[data-shape]')];
|
||||||
|
setInterval(()=>{ const mid=innerHeight/2; let best=null,bd=1e9; for(const r of rows){ const b=r.getBoundingClientRect(); if(b.bottom<0||b.top>innerHeight)continue; const d=Math.abs((b.top+b.bottom)/2-mid); if(d<bd){bd=d;best=r;} } if(best&&bd<innerHeight*0.3)hoverIn(best); else hoverOut(); },250);
|
||||||
|
}
|
||||||
|
// timeline beam
|
||||||
|
const tl=document.getElementById('tl'); new IntersectionObserver(es=>es.forEach(e=>{ if(e.isIntersecting){tl.classList.add('in')} }),{threshold:0.2}).observe(tl);
|
||||||
|
|
||||||
|
// tilt on projects (pointer-driven only)
|
||||||
|
if(!coarse&&!reduce){
|
||||||
|
document.querySelectorAll('[data-tilt]').forEach(el=>{
|
||||||
|
el.addEventListener('pointermove',e=>{ const r=el.getBoundingClientRect(); const x=(e.clientX-r.left)/r.width-0.5, y=(e.clientY-r.top)/r.height-0.5; el.style.transform='perspective(900px) rotateX('+(-y*6)+'deg) rotateY('+(x*8)+'deg) translateZ(6px)'; });
|
||||||
|
el.addEventListener('pointerleave',()=>{ el.style.transform=''; });
|
||||||
|
});
|
||||||
|
// magnetic links
|
||||||
|
document.querySelectorAll('[data-mag]').forEach(el=>{
|
||||||
|
el.addEventListener('pointermove',e=>{ const r=el.getBoundingClientRect(); const x=e.clientX-(r.left+r.width/2), y=e.clientY-(r.top+r.height/2); el.style.transform='translate('+x*0.25+'px,'+y*0.35+'px)'; el.style.transition='transform .15s'; });
|
||||||
|
el.addEventListener('pointerleave',()=>{ el.style.transition='transform .5s cubic-bezier(.16,1,.3,1)'; el.style.transform=''; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// custom cursor
|
||||||
|
if(!coarse){
|
||||||
|
const dot=document.querySelector('.cur'), ring=document.querySelector('.cur-ring'); let cx=innerWidth/2,cy=innerHeight/2,rx=cx,ry=cy;
|
||||||
|
addEventListener('pointermove',e=>{cx=e.clientX;cy=e.clientY;},{passive:true});
|
||||||
|
(function cl(){ rx+=(cx-rx)*0.18; ry+=(cy-ry)*0.18; dot.style.transform='translate('+cx+'px,'+cy+'px) translate(-50%,-50%)'; ring.style.transform='translate('+rx+'px,'+ry+'px) translate(-50%,-50%)'; requestAnimationFrame(cl); })();
|
||||||
|
document.querySelectorAll('a,button').forEach(a=>{ a.addEventListener('pointerenter',()=>ring.classList.add('big')); a.addEventListener('pointerleave',()=>ring.classList.remove('big')); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// smooth anchor scroll
|
||||||
|
document.querySelectorAll('nav a[href^="#"]').forEach(a=>a.addEventListener('click',e=>{ const t=document.querySelector(a.getAttribute('href')); if(t){e.preventDefault(); t.scrollIntoView({behavior:reduce?'auto':'smooth'});} }));
|
||||||
|
|
||||||
|
/* boot: wait for the display font so the PQ sampling is right, then go */
|
||||||
|
function boot(){
|
||||||
|
if(!renderer)return;
|
||||||
|
buildParticles(); resize(); updateScroll(); frame();
|
||||||
|
setTimeout(()=>name.classList.add('on'),150);
|
||||||
|
}
|
||||||
|
if(document.fonts&&document.fonts.load){ Promise.race([document.fonts.load('800 300px Syne'),new Promise(r=>setTimeout(r,1500))]).then(boot,boot); } else boot();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
-121
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 140 KiB |
@@ -1,82 +0,0 @@
|
|||||||
import type { Config } from "tailwindcss";
|
|
||||||
|
|
||||||
const config: Config = {
|
|
||||||
darkMode: ["class"],
|
|
||||||
content: [
|
|
||||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
nord: {
|
|
||||||
'0': '#2E3440',
|
|
||||||
'1': '#3B4252',
|
|
||||||
'2': '#434C5E',
|
|
||||||
'3': '#4C566A',
|
|
||||||
'4': '#D8DEE9',
|
|
||||||
'5': '#E5E9F0',
|
|
||||||
'6': '#ECEFF4',
|
|
||||||
'7': '#8FBCBB',
|
|
||||||
'8': '#88C0D0',
|
|
||||||
'9': '#81A1C1',
|
|
||||||
'10': '#5E81AC',
|
|
||||||
'11': '#BF616A',
|
|
||||||
'12': '#D08770',
|
|
||||||
'13': '#EBCB8B',
|
|
||||||
'14': '#A3BE8C',
|
|
||||||
'15': '#B48EAD'
|
|
||||||
},
|
|
||||||
background: 'hsl(var(--background))',
|
|
||||||
foreground: 'hsl(var(--foreground))',
|
|
||||||
card: {
|
|
||||||
DEFAULT: 'hsl(var(--card))',
|
|
||||||
foreground: 'hsl(var(--card-foreground))'
|
|
||||||
},
|
|
||||||
popover: {
|
|
||||||
DEFAULT: 'hsl(var(--popover))',
|
|
||||||
foreground: 'hsl(var(--popover-foreground))'
|
|
||||||
},
|
|
||||||
primary: {
|
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
|
||||||
foreground: 'hsl(var(--primary-foreground))'
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
|
||||||
foreground: 'hsl(var(--secondary-foreground))'
|
|
||||||
},
|
|
||||||
muted: {
|
|
||||||
DEFAULT: 'hsl(var(--muted))',
|
|
||||||
foreground: 'hsl(var(--muted-foreground))'
|
|
||||||
},
|
|
||||||
accent: {
|
|
||||||
DEFAULT: 'hsl(var(--accent))',
|
|
||||||
foreground: 'hsl(var(--accent-foreground))'
|
|
||||||
},
|
|
||||||
destructive: {
|
|
||||||
DEFAULT: 'hsl(var(--destructive))',
|
|
||||||
foreground: 'hsl(var(--destructive-foreground))'
|
|
||||||
},
|
|
||||||
border: 'hsl(var(--border))',
|
|
||||||
input: 'hsl(var(--input))',
|
|
||||||
ring: 'hsl(var(--ring))',
|
|
||||||
chart: {
|
|
||||||
'1': 'hsl(var(--chart-1))',
|
|
||||||
'2': 'hsl(var(--chart-2))',
|
|
||||||
'3': 'hsl(var(--chart-3))',
|
|
||||||
'4': 'hsl(var(--chart-4))',
|
|
||||||
'5': 'hsl(var(--chart-5))'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
borderRadius: {
|
|
||||||
lg: 'var(--radius)',
|
|
||||||
md: 'calc(var(--radius) - 2px)',
|
|
||||||
sm: 'calc(var(--radius) - 4px)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: [require("tailwindcss-animate")],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
+9
-42
@@ -1,50 +1,17 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": [
|
"target": "ES2020",
|
||||||
"dom",
|
"useDefineForClassFields": true,
|
||||||
"dom.iterable",
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"es6"
|
"module": "ESNext",
|
||||||
],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"noEmit": true,
|
||||||
"incremental": true,
|
"jsx": "react-jsx",
|
||||||
"plugins": [
|
"strict": true,
|
||||||
{
|
"types": ["vite/client"]
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"./*"
|
|
||||||
],
|
|
||||||
"@/components/*": [
|
|
||||||
"./components/*"
|
|
||||||
],
|
|
||||||
"@/lib/*": [
|
|
||||||
"./lib/*"
|
|
||||||
],
|
|
||||||
"@/types/*": [
|
|
||||||
"./types/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src", "vite.config.ts"]
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts",
|
|
||||||
"out/types/**/*.ts"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/whoami/',
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user