88 Commits
Author SHA1 Message Date
Quinta 49c79b31a9 added other links 2026-09-19 02:38:55 +02:00
Quinta b37dd7ee3c added nargothron-watcher 2026-09-17 18:30:15 +02:00
Quinta 9a399aba61 mobile layout 2026-09-16 00:21:16 +02:00
Quinta cb88c84777 aspect ratioed 2026-09-16 00:04:23 +02:00
Quinta 6cbe8ae739 Claudio cooked 2026-09-15 23:36:44 +02:00
Quinta 6b139c9901 contour grid 2026-09-15 22:49:14 +02:00
Quinta f400f63321 3js 2026-09-15 17:58:27 +02:00
Quinta bdacc4a895 arrow fix 2026-09-14 21:01:55 +02:00
Quinta 821c8ae6a2 Remove CV Dossier link from footer
Removed CV Dossier link from footer.
2026-09-14 20:57:19 +02:00
Quinta 97a6042782 Remove logo link from site header
Removed logo link from the header.
2026-09-14 20:56:26 +02:00
Quinta 19cce7d75e Update experience period for freelance consultant role 2026-09-14 20:54:52 +02:00
Quinta 1ba1c3123d Delete public directory 2026-09-14 20:53:46 +02:00
Quinta 5e15524ace Remove technician approach from background section
Removed technician approach section from background.
2026-09-14 20:52:53 +02:00
Quinta 4f47a19fd7 restyle 2026-09-14 20:49:39 +02:00
Quinta 0579aa2448 more OSS contribs 2026-08-27 01:14:08 +02:00
Quinta b1ec646587 UI fix 2026-08-09 20:51:55 +02:00
Quinta 03b3e958cb remade in Vitejs 2026-08-09 20:48:12 +02:00
Quinta 23a7c03da5 Merge pull request #16 from Quinta0/dependabot/npm_and_yarn/postcss-8.5.18
Bump postcss from 8.5.10 to 8.5.18
2026-07-28 23:13:59 +02:00
Quinta ca28583823 more contributions 2026-07-28 19:12:58 +02:00
dependabot[bot] c0ec5cf2b2 Bump postcss from 8.5.10 to 8.5.18
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.10 to 8.5.18.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.10...8.5.18)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.18
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 17:35:34 +00:00
Quinta b103da0ad7 Merge pull request #15 from Quinta0/dependabot/npm_and_yarn/next-15.5.21
Bump next from 15.5.18 to 15.5.21
2026-07-27 19:34:21 +02:00
dependabot[bot] 09411c50cc Bump next from 15.5.18 to 15.5.21
Bumps [next](https://github.com/vercel/next.js) from 15.5.18 to 15.5.21.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.18...v15.5.21)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.21
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 07:38:19 +00:00
Quinta 78393c24a1 Fixes in grammar 2026-07-25 19:53:49 +02:00
Quinta 7f328ae3ea Deutch,France and italiano 2026-07-25 19:33:51 +02:00
Quinta 59184d9164 replaced the hardcoded :first-child/:not(:last-child) rules with :nth-child(Nn+1)/:nth-child(Nn) math scoped per breakpoint (5/3/2 columns), plus a single higher-specificity 2026-07-25 17:00:41 +02:00
Quinta 0dccb99b76 Added homelab graph 2026-07-24 21:51:38 +02:00
Quinta 698b76fc86 Recol/DLSS-Updater PR 2026-07-24 19:56:05 +02:00
Quinta 931ae86e7d fixed some paddings 2026-07-23 23:24:05 +02:00
Quinta 9dbf8e4d6a Added riptune to the my projects on gh 2026-07-23 23:00:13 +02:00
Quinta 25dfe21097 fixed nav 2026-07-23 18:42:29 +02:00
Quinta d056462f81 refresh 2026-07-23 18:17:12 +02:00
Quinta c087d42a1c Delete README.md 2026-07-23 17:45:56 +02:00
Quinta 464da42e38 Revamp and restyle 2026-07-23 17:44:49 +02:00
Quinta 688f929e01 Update page and dependencies 2026-07-21 20:11:10 +02:00
Quinta 6cd828130a Add 'Dispatch' personal project section
Added a new personal project 'Dispatch' with details and tags.
2026-07-06 17:15:10 +02:00
Quinta 8e93d167d3 Merge pull request #14 from Quinta0/dependabot/npm_and_yarn/multi-06523197de
Bump glob, sucrase, eslint and eslint-config-next
2026-07-05 22:35:22 +02:00
dependabot[bot] 5940147513 Bump glob, sucrase, eslint and eslint-config-next
Removes [glob](https://github.com/isaacs/node-glob). It's no longer used after updating ancestor dependencies [glob](https://github.com/isaacs/node-glob), [sucrase](https://github.com/alangpierce/sucrase), [eslint](https://github.com/eslint/eslint) and [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next). These dependencies need to be updated together.


Removes `glob`

Updates `sucrase` from 3.35.0 to 3.35.1
- [Changelog](https://github.com/alangpierce/sucrase/blob/main/CHANGELOG.md)
- [Commits](https://github.com/alangpierce/sucrase/commits)

Updates `eslint` from 8.57.1 to 10.6.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v8.57.1...v10.6.0)

Updates `eslint-config-next` from 14.2.13 to 16.2.10
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.10/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: glob
  dependency-version:
  dependency-type: indirect
- dependency-name: sucrase
  dependency-version: 3.35.1
  dependency-type: indirect
- dependency-name: eslint
  dependency-version: 10.6.0
  dependency-type: direct:development
- dependency-name: eslint-config-next
  dependency-version: 16.2.10
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-05 20:34:18 +00:00
Quinta 580be42af4 Merge pull request #13 from Quinta0/copilot/fix-deploy-failing-job
[WIP] Fix failing GitHub Actions job deploy
2026-07-05 22:34:08 +02:00
Quinta db01f865f1 Merge pull request #5 from Quinta0/dependabot/npm_and_yarn/multi-237e9862f4
Bump brace-expansion
2026-07-05 22:33:13 +02:00
Quinta dcd35386ae Merge pull request #4 from Quinta0/dependabot/npm_and_yarn/multi-bf05dc1ecf
Bump picomatch
2026-07-05 22:32:59 +02:00
Quinta 4d34520e19 Merge pull request #6 from Quinta0/dependabot/npm_and_yarn/yaml-2.8.4
Bump yaml from 2.8.0 to 2.8.4
2026-07-05 22:32:48 +02:00
Quinta 5dca84ba70 Merge pull request #10 from Quinta0/dependabot/npm_and_yarn/next-15.5.18
Bump next from 14.2.13 to 15.5.18
2026-07-05 22:32:32 +02:00
Quinta 79dac26536 Merge pull request #12 from Quinta0/dependabot/npm_and_yarn/js-yaml-4.3.0
Bump js-yaml from 4.1.0 to 4.3.0
2026-07-05 22:32:03 +02:00
Quinta 2578befb0e Merge pull request #8 from Quinta0/dependabot/npm_and_yarn/postcss-8.5.10
Bump postcss from 8.5.6 to 8.5.10
2026-07-05 22:30:55 +02:00
Quinta 2da4afe11b Merge pull request #7 from Quinta0/dependabot/npm_and_yarn/flatted-3.4.2
Bump flatted from 3.3.3 to 3.4.2
2026-07-05 22:30:06 +02:00
Quinta ff7422fb3f Replace homelab project with Nargothrond documentation 2026-07-04 23:53:09 +02:00
Quinta 31e7b33c40 Update hint text in input field 2026-07-04 23:14:01 +02:00
Quinta 2d004d934a Remove non-breaking space in t-body div
Removed a non-breaking space from the t-body div.
2026-07-04 20:39:14 +02:00
Quinta 953c11bf09 Remove empty non-breaking space div
Removed unnecessary non-breaking space div.
2026-07-04 20:32:28 +02:00
Quinta 354471daf6 Change terminal title from 'proxmox' to 'server' 2026-07-04 20:29:26 +02:00
copilot-swe-agent[bot] 9a05b71aea Initial plan 2026-07-04 18:25:17 +00:00
Quinta d683d9ace6 uovo di pasqua 2026-07-04 20:22:07 +02:00
dependabot[bot] ce362ce65a Bump js-yaml from 4.1.0 to 4.3.0
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.3.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.3.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-02 09:27:50 +00:00
Quinta 64f3207224 Update availability text for consulting roles 2026-06-03 12:48:59 +02:00
Quinta c419d566da experience bullets made outcome-focused, hero copy sharpened, and all CSS
fixes applied.
2026-06-02 12:42:59 +02:00
Quinta 6b8c03dbf4 CSS — col-rule-c1 and col-rule-c2 are now display:none globally so they won't appear at any viewport width. 2026-06-02 01:37:42 +02:00
Quinta 1ad48261b8 Update OS and Role information in page.tsx 2026-06-02 01:20:24 +02:00
QuintaandClaude Sonnet 4.6 56835ec562 redesign: port index.html stone/beige design into Next.js app
Replace Nord dark-mode theme with the stone/beige grid-based design from
index.html. Rewrites globals.css with the full CSS system, updates layout.tsx
fonts to Syne + DM Sans + DM Mono, and ports the entire HTML to page.tsx with
React accordion state. Adds Desktop section with Ryzen 7 9800X3D / RTX 5080
specs and corrects UNRAID server hardware (Core Ultra 5 225, 48GB DDR5,
UNRAID 7.3.1).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 01:14:07 +02:00
dependabot[bot] 22631513cd Bump next from 14.2.13 to 15.5.18
Bumps [next](https://github.com/vercel/next.js) from 14.2.13 to 15.5.18.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.13...v15.5.18)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.18
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-13 20:41:48 +00:00
dependabot[bot] 852c2249a9 Bump postcss from 8.5.6 to 8.5.10
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.6 to 8.5.10.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.10)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.10
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-07 22:18:35 +00:00
dependabot[bot] 88b81063ca Bump flatted from 3.3.3 to 3.4.2
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-07 22:18:31 +00:00
dependabot[bot] 9503b0501c Bump yaml from 2.8.0 to 2.8.4
Bumps [yaml](https://github.com/eemeli/yaml) from 2.8.0 to 2.8.4.
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v2.8.0...v2.8.4)

---
updated-dependencies:
- dependency-name: yaml
  dependency-version: 2.8.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-07 22:18:23 +00:00
dependabot[bot] 4fa24c7c18 Bump brace-expansion
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 2.0.2 to 2.1.0
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.0.2...v2.1.0)

Updates `brace-expansion` from 1.1.12 to 1.1.14
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.0.2...v2.1.0)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 2.1.0
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 1.1.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-07 22:18:16 +00:00
dependabot[bot] d6c6e487ff Bump picomatch
Bumps  and [picomatch](https://github.com/micromatch/picomatch). These dependencies needed to be updated together.

Updates `picomatch` from 2.3.1 to 2.3.2
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

Updates `picomatch` from 4.0.2 to 4.0.4
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/2.3.1...2.3.2)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 2.3.2
  dependency-type: indirect
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-07 22:18:14 +00:00
Quinta 6d89555256 changed favicon 2025-12-17 23:34:22 +01:00
Quinta 35f1a9b3d2 Update email address and improve personal summary 2025-12-14 19:53:32 +01:00
Quinta 691f1bafcb changed colors 2025-12-08 02:27:30 +01:00
Quinta 1905af853f revamp all 2025-12-08 02:08:23 +01:00
Quinta f4af7ac32c fixes for build 2025-10-15 15:27:24 +02:00
Quinta a9db70a3da revamp 2025-10-15 15:20:24 +02:00
Quinta 5c064b6201 Rename Quintavalle Pietro CV eng-6.pdf to Pietro_Quintavalle_CV.pdf 2025-08-11 17:28:17 +02:00
Quinta b79b813acc Add files via upload 2025-08-11 17:26:52 +02:00
Quinta 0e86e7e075 Delete public/Pietro_Quintavalle_CV.pdf 2025-08-11 17:18:15 +02:00
Quinta 40796c2e88 fixed CV stuff 2025-08-07 15:15:37 +02:00
Quinta 5429a17ed6 fiixes 2025-08-07 15:08:09 +02:00
Quinta 324cc73153 revamp 2025-08-07 13:37:08 +02:00
Quinta b38fe4ab86 Added images 2025-06-26 17:26:59 +02:00
Quinta e7647fb80b refactor: enhance Nord theme styles and update Tailwind configuration 2025-06-26 17:14:20 +02:00
Quinta 5d93919fb2 refactor: update showProjectGallery state initialization and onClose handler for clarity 2025-06-26 17:08:03 +02:00
Quinta 9e4a6a8c95 refactor: add TypeScript types to scrollToSection and ProjectGallery components 2025-06-26 17:05:33 +02:00
Quinta 45db885cf3 refactor: update ESLint rules, remove unused Portfolio component 2025-06-26 17:02:24 +02:00
Quinta d8c6bd6586 refactor: update CSS variables and styles for Nord theme, add custom scrollbar and smooth scrolling 2025-06-26 16:56:04 +02:00
Quinta 73677afdbe refactor: clean up formatting and improve code readability in Portfolio component 2025-06-26 16:50:57 +02:00
Quinta 554bf9ba93 pkg lock 2025-06-26 16:49:10 +02:00
Quinta 88be8d7aca Merge pull request #3 from Quinta0/refactor
static export
2025-06-26 16:26:02 +02:00
Quinta 01271f56b5 Merge pull request #2 from Quinta0/refactor
fixes
2025-06-26 16:21:04 +02:00
Quinta 0da3a9fe76 Merge pull request #1 from Quinta0/refactor
update
2025-06-26 15:49:03 +02:00
Quinta 09630a949c Update page.tsx 2025-06-26 15:30:28 +02:00
52 changed files with 2855 additions and 1643 deletions
-8
View File
@@ -1,8 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "warn",
"react-hooks/exhaustive-deps": "warn"
}
}
+48
View File
@@ -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
-93
View File
@@ -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
View File
@@ -9,12 +9,8 @@
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# vite
/dist/
# misc
.DS_Store
@@ -28,9 +24,7 @@ yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/resume
-5
View File
@@ -1,5 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
-6
View File
@@ -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>
-8
View File
@@ -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>
-12
View File
@@ -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
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+8
View File
@@ -0,0 +1,8 @@
{
"hash": "c1692715",
"configHash": "46535a3e",
"lockfileHash": "e3b0c442",
"browserHash": "7d791f90",
"optimized": {},
"chunks": {}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
-36
View File
@@ -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.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.
Binary file not shown.
-92
View File
@@ -1,92 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
-33
View File
@@ -1,33 +0,0 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'Pietro Quintavalle - Economics Student & Developer',
description: 'Economics student at USI with extensive data analytics and web development expertise. Google-certified in Data Analytics and IT Support.',
keywords: ['Pietro Quintavalle', 'Economics', 'Data Analytics', 'Web Development', 'Switzerland', 'USI'],
authors: [{ name: 'Pietro Quintavalle' }],
openGraph: {
title: 'Pietro Quintavalle - Economics Student & Developer',
description: 'Economics student at USI with extensive data analytics and web development expertise.',
url: 'https://your-domain.com',
siteName: 'Pietro Quintavalle Portfolio',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'Pietro Quintavalle - Economics Student & Developer',
description: 'Economics student at USI with extensive data analytics and web development expertise.',
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
-235
View File
@@ -1,235 +0,0 @@
"use client";
import Link from "next/link"
import { Button } from "@/components/ui/button"
import {JSX, SVGProps, useState} from "react"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import Image from "next/image"
import {Github, Linkedin, MenuIcon} from "lucide-react";
export default function Home() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const skills = [
"IT Support", "Data Analysis", "Python", "R", "SQL", "Network Architecture",
"Network Security", "Cloud Computing", "Data Visualization", "Machine Learning",
"Statistical Analysis", "Database Management", "Linux", "Git", "Agile Methodologies",
"Project Management", "Business Analysis", "Critical Thinking"
];
const certifications = [
{
name: "Google IT Support Professional Certificate",
issuer: "Google",
image: "./certificates/Coursera_RZ1V4RU6JP2D.png"
},
{
name: "Google Data Analytics Professional Certificate",
issuer: "Google",
image: "./certificates/Coursera_EN181NFH7VYT.png"
},
{
name: "IBM Python for Data Science, AI & Development",
issuer: "IBM",
image: "./certificates/Coursera_QT9WT5VCHJNU.png"
}
];
return (
<div className="bg-[#00004d] text-white font-['Segoe UI', 'FiraCode'] min-h-screen">
<header className="py-4 px-6 flex items-center justify-between border-b border-[#4d4dff]">
<div className="flex items-center gap-4">
<TerminalIcon className="w-8 h-8" aria-hidden="true" />
<h1 className="text-2xl font-semibold">Pietro Quintavalle</h1>
</div>
<nav className="hidden md:flex items-center gap-6">
{["About", "Skills", "Portfolio", "Certifications"].map((item) => (
<Link key={item} href={`#${item.toLowerCase()}`} className="hover:text-[#4d4dff] transition-colors p-2">
{item}
</Link>
))}
</nav>
<button
className="md:hidden text-white p-2"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
>
<MenuIcon className="h-6 w-6" aria-hidden="true" />
</button>
</header>
{/* Mobile menu */}
{mobileMenuOpen && (
<div className="md:hidden bg-[#0000b3] py-2">
{["About", "Skills", "Certifications", "Portfolio"].map((item) => (
<Link
key={item}
href={`#${item.toLowerCase()}`}
className="block py-2 px-4 text-white hover:bg-[#4d4dff] transition-colors"
onClick={() => setMobileMenuOpen(false)}
>
{item}
</Link>
))}
</div>
)}
<main className="px-6 py-12">
<section id="about" className="mb-12">
<h2 className="text-3xl font-semibold mb-4">About Me</h2>
<div className="prose prose-invert max-w-3xl">
<p>
Hello! I&apos;m Pietro Quintavalle, a dedicated IT hobbyist with a strong foundation in IT support and
data analytics who&apos;s currently pursuing a bachelor in Economics.
My passion lies in leveraging technology to solve complex problems and derive meaningful insights from
data.
With a combination of technical skills and analytical thinking, I&apos;m equipped to tackle challenges
in both IT infrastructure and data-driven decision-making.
</p>
</div>
<div className="flex space-x-4 mt-4">
<Link href="https://github.com/Quinta0" passHref>
<a aria-label="GitHub Profile">
<Button variant="outline" size="icon" className="p-2">
<Github className="h-6 w-6" aria-hidden="true"/>
</Button>
</a>
</Link>
<Link href="https://linkedin.com/in/pietro-quintavalle-996b96267" passHref>
<a aria-label="LinkedIn Profile">
<Button variant="outline" size="icon" className="p-2">
<Linkedin className="h-6 w-6" aria-hidden="true"/>
</Button>
</a>
</Link>
</div>
</section>
<section id="skills" className="mb-16">
<h2 className="text-3xl font-bold mb-4">Skills</h2>
<div className="relative overflow-hidden h-16">
<div className="absolute flex animate-marquee">
{[...skills, ...skills].map((skill, index) => (
<Badge key={index} variant="secondary" className="mx-2 text-md text-center py-1 px-3">
{skill}
</Badge>
))}
</div>
</div>
</section>
<section id="portfolio" className="mb-12">
<h2 className="text-3xl font-semibold mb-4">Portfolio</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{
title: "password-generator",
description: "A unique and interactive password generator built with Next.js and React. ",
technologies: ["Nextjs", "Encryption", "Typescript"],
githubLink: "https://github.com/Quinta0/password-generator"
},
{
title: "Valgo",
description: "A website for visualizing sorting algorithms for the visual learners out there.",
technologies: ["Typescript", "React", "Algorithms"],
githubLink: "https://github.com/Quinta0/valgo"
},
{
title: "Gravity",
description: "3D Gravity Simulator is a C++ program that visualizes the gravitational interactions between celestial bodies",
technologies: ["C++", "OpenGL", "Physics", "3D", "Simulation", "Cmake", "VCPKG"],
githubLink: "https://github.com/Quinta0/gravity"
},
{
title: "Boston Housing Price Prediction",
description: "A simulation of the Boston Housing Price Prediction project that involves building a machine learning model to predict housing prices. ",
technologies: ["Python", "Machine Learning", "Data Analysis", "Data Visualization", "Jupyter Notebook", "Probability", "Statistics"],
githubLink: "https://github.com/Quinta0/BostonHousing"
},
{
title: "Forest Fire Simulation",
description: "An assignment for the Probability & Statistics course at USI that aims to model the spread of forest fires over a grid representing a forest landscape.",
technologies: ["Statistics", "Python", "Jupyter Notebook", "Simulation", "Probability"],
githubLink: "https://github.com/Quinta0/ForestFire"
},
{
title: "AequitasIQ",
description: "An intelligent financial analysis tool that creates an interactive dashboard to visualize personal income and expenses.",
technologies: ["Python", "Pandas", "LLM", "Ollama", "Plotly", "NumPy", "LangChain"],
githubLink: "https://github.com/Quinta0/AequitasIQ-.git"
}
].map((project, index) => (
<div key={index} className="bg-[#0000b3] rounded-md p-4">
<h3 className="text-xl font-semibold mb-2">{project.title}</h3>
<p className="mb-4">{project.description}</p>
<div className="mb-4">
{project.technologies.map((tech, techIndex) => (
<span key={techIndex}
className="inline-block bg-[#4d4dff] text-white rounded-full px-3 py-1 text-sm font-semibold mr-2 mb-2">
{tech}
</span>
))}
</div>
<div className="flex justify-end">
<Link
href={project.githubLink}
className="inline-flex items-center rounded-md bg-[#4d4dff] px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-[#3b3bd9] focus:outline-none focus:ring-2 focus:ring-[#4d4dff] focus:ring-offset-2"
aria-label={`View ${project.title} on GitHub`}
>
View on GitHub
</Link>
</div>
</div>
))}
</div>
</section>
<section id="certifications" className="mb-16">
<h2 className="text-3xl font-semibold mb-4">Certifications</h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{certifications.map((cert, index) => (
<Card key={index} className="overflow-hidden bg-[#0000b3] text-white">
<Image src={cert.image} alt={cert.name} width={400} height={300}
className="w-full object-contain"/>
<CardHeader>
<CardTitle>{cert.name}</CardTitle>
<CardDescription className="text-gray-300">{cert.issuer}</CardDescription>
</CardHeader>
</Card>
))}
</div>
</section>
</main>
<footer className="bg-[#0000b3] py-6 px-6 text-center text-sm">
<p>&copy; 2024 Pietro Quintavalle. All rights reserved.</p>
</footer>
<style jsx>{`
@keyframes marquee {
0% {
transform: translateX(100%);
}
100% {
transform: translateX(-100%);
}
}
.animate-marquee {
animation: marquee 30s linear infinite;
}
`}</style>
</div>
)
}
function TerminalIcon(props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="4 17 10 11 4 5"/>
<line x1="12" x2="20" y1="19" y2="19"/>
</svg>
)
}
-20
View File
@@ -1,20 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"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"
}
}
-721
View File
@@ -1,721 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { Github, Linkedin, Mail, ExternalLink, ChevronDown, Code, Database, BarChart3, Globe, Award, GraduationCap, Image, ExternalLink as LinkIcon } from 'lucide-react';
// Types
interface NordColors {
bg: string;
bgLight: string;
bgLighter: string;
bgLightest: string;
text: string;
textMuted: string;
accent: string;
blue: string;
darkBlue: string;
cyan: string;
green: string;
yellow: string;
orange: string;
red: string;
purple: string;
}
interface Project {
title: string;
period: string;
description: string;
tech: string[];
icon: React.ReactNode;
status: string;
images: string[];
features: string[];
github?: string;
demo?: string;
}
interface Education {
institution: string;
degree: string;
period: string;
status: string;
}
interface ProjectGalleryProps {
project: Project;
onClose: () => void;
}
const Portfolio: React.FC = () => {
const [activeSection, setActiveSection] = useState<string>('home');
const [scrolled, setScrolled] = useState<boolean>(false);
const [showProjectGallery, setShowProjectGallery] = useState<Project | false>(false);
// Nord Color Palette
const nordColors: NordColors = {
bg: '#2E3440',
bgLight: '#3B4252',
bgLighter: '#434C5E',
bgLightest: '#4C566A',
text: '#D8DEE9',
textMuted: 'rgba(216, 222, 233, 0.6)',
accent: '#88C0D0',
blue: '#81A1C1',
darkBlue: '#5E81AC',
cyan: '#8FBCBB',
green: '#A3BE8C',
yellow: '#EBCB8B',
orange: '#D08770',
red: '#BF616A',
purple: '#B48EAD'
};
useEffect(() => {
const handleScroll = (): void => {
setScrolled(window.scrollY > 50);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const skills: Record<string, string[]> = {
'Data & Analytics': ['Python', 'R', 'SQL', 'Statistical Analysis', 'Data Visualization', 'Business Intelligence'],
'Web Development': ['JavaScript', 'TypeScript', 'React', 'Next.js', 'HTML5', 'CSS3', 'Tailwind CSS'],
'Backend & Tools': ['Flask', 'Express', 'MongoDB', 'SQLite', 'Git', 'Linux'],
'Other': ['Java', 'LaTeX', 'Project Planning', 'Financial Analysis']
};
const projects: Project[] = [
{
title: 'Valgo - Algorithm Visualizer',
period: 'Jun 2024 - Present',
description: 'Interactive web application designed to help users understand and visualize various sorting algorithms. Built with Next.js and React to make learning algorithms more engaging and intuitive.',
tech: ['Next.js', 'React', 'JavaScript', 'Algorithm Visualization'],
icon: <Code className="w-6 h-6" />,
status: 'Live',
images: [
'https://via.placeholder.com/600x400/81A1C1/2E3440?text=Algorithm+Visualizer',
'https://via.placeholder.com/600x400/88C0D0/2E3440?text=Sorting+Demo',
'https://via.placeholder.com/600x400/5E81AC/2E3440?text=Interactive+UI'
],
features: ['Real-time algorithm visualization', 'Multiple sorting algorithms', 'Step-by-step breakdown', 'Performance comparison'],
github: 'https://github.com/Quinta0/valgo',
demo: 'https://valgo-demo.vercel.app'
},
{
title: 'Boston Housing Price Prediction',
period: 'May 2024 - Present',
description: 'Machine learning model to predict housing prices in the Boston area based on various features such as crime rate, average number of rooms per dwelling, and accessibility to radial highways.',
tech: ['Python', 'Machine Learning', 'Data Analysis', 'Statistical Modeling'],
icon: <BarChart3 className="w-6 h-6" />,
status: 'In Progress',
images: [
'https://via.placeholder.com/600x400/A3BE8C/2E3440?text=Data+Visualization',
'https://via.placeholder.com/600x400/EBCB8B/2E3440?text=ML+Model',
'https://via.placeholder.com/600x400/D08770/2E3440?text=Results+Analysis'
],
features: ['Predictive modeling', 'Feature engineering', 'Data visualization', 'Statistical analysis'],
github: 'https://github.com/Quinta0/boston-housing'
},
{
title: 'Forest Fire Simulation',
period: 'Mar 2024 - Present',
description: 'Probability & Statistics course project modeling the spread of forest fires over a grid. Accounts for vegetation types, terrain elevation, wind patterns, humidity levels, and spontaneous ignition.',
tech: ['Python', 'Statistical Modeling', 'Simulation', 'Data Science'],
icon: <Database className="w-6 h-6" />,
status: 'Academic Project',
images: [
'https://via.placeholder.com/600x400/BF616A/2E3440?text=Fire+Simulation',
'https://via.placeholder.com/600x400/B48EAD/2E3440?text=Statistical+Model',
'https://via.placeholder.com/600x400/8FBCBB/2E3440?text=Data+Analysis'
],
features: ['Monte Carlo simulation', 'Environmental factors modeling', 'Statistical analysis', 'Visualization tools'],
github: 'https://github.com/Quinta0/forest-fire-sim'
}
];
const education: Education[] = [
{
institution: 'USI Università della Svizzera italiana',
degree: "Bachelor's degree Economics",
period: 'Sep 2024 - Jun 2027',
status: 'Current'
},
{
institution: 'USI Università della Svizzera italiana',
degree: 'Bachelor of Science - BS Informatics',
period: 'Jan 2022 - Jan 2024',
status: 'Completed'
},
{
institution: 'Scuola cantonale di commercio',
degree: 'Maturità Cantonale e AFC Economics',
period: 'Sep 2017 - Jun 2021',
status: 'GPA: 4.5/5'
}
];
const certificates: string[] = [
'Google Data Analytics Professional Certificate',
'Google IT Support Professional Certificate',
'IT Security: Defense against the digital dark arts',
'Python for Data Science, AI & Development'
];
const scrollToSection = (sectionId: string): void => {
document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth' });
setActiveSection(sectionId);
};
const ProjectGallery: React.FC<ProjectGalleryProps> = ({ project, onClose }) => (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(46, 52, 64, 0.95)' }}
onClick={onClose}
>
<div
className="max-w-4xl w-full max-h-[90vh] overflow-y-auto rounded-lg"
style={{ backgroundColor: nordColors.bgLight }}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<div className="p-6">
<div className="flex justify-between items-start mb-6">
<div>
<h3 className="text-2xl font-bold mb-2" style={{ color: nordColors.text }}>
{project.title}
</h3>
<div className="flex items-center gap-4">
<span className="text-sm" style={{ color: nordColors.blue }}>
{project.period}
</span>
<span
className="px-3 py-1 rounded-full text-xs"
style={{
backgroundColor: nordColors.darkBlue,
color: nordColors.text
}}
>
{project.status}
</span>
</div>
</div>
<button
onClick={onClose}
className="text-2xl hover:opacity-70"
style={{ color: nordColors.textMuted }}
>
×
</button>
</div>
{/* Project Images */}
<div className="grid md:grid-cols-2 gap-4 mb-6">
{project.images.map((image: string, idx: number) => (
<div key={idx} className="aspect-video rounded-lg overflow-hidden">
<img
src={image}
alt={`${project.title} screenshot ${idx + 1}`}
className="w-full h-full object-cover"
/>
</div>
))}
</div>
{/* Description */}
<div className="mb-6">
<h4 className="text-lg font-semibold mb-3" style={{ color: nordColors.blue }}>
About This Project
</h4>
<p className="leading-relaxed" style={{ color: nordColors.textMuted }}>
{project.description}
</p>
</div>
{/* Features */}
<div className="mb-6">
<h4 className="text-lg font-semibold mb-3" style={{ color: nordColors.blue }}>
Key Features
</h4>
<div className="grid md:grid-cols-2 gap-2">
{project.features.map((feature: string, idx: number) => (
<div key={idx} className="flex items-center">
<div
className="w-2 h-2 rounded-full mr-3"
style={{ backgroundColor: nordColors.accent }}
></div>
<span style={{ color: nordColors.text }}>{feature}</span>
</div>
))}
</div>
</div>
{/* Tech Stack */}
<div className="mb-6">
<h4 className="text-lg font-semibold mb-3" style={{ color: nordColors.blue }}>
Technology Stack
</h4>
<div className="flex flex-wrap gap-2">
{project.tech.map((tech: string) => (
<span
key={tech}
className="px-3 py-1 rounded text-sm"
style={{
backgroundColor: nordColors.bg,
color: nordColors.accent
}}
>
{tech}
</span>
))}
</div>
</div>
{/* Links */}
<div className="flex gap-4">
{project.github && (
<a
href={project.github}
className="flex items-center gap-2 px-4 py-2 rounded hover:opacity-80 transition-opacity"
style={{ backgroundColor: nordColors.bgLighter, color: nordColors.text }}
target="_blank"
rel="noopener noreferrer"
>
<Github className="w-4 h-4" />
View Code
</a>
)}
{project.demo && (
<a
href={project.demo}
className="flex items-center gap-2 px-4 py-2 rounded hover:opacity-80 transition-opacity"
style={{ backgroundColor: nordColors.darkBlue, color: nordColors.text }}
target="_blank"
rel="noopener noreferrer"
>
<LinkIcon className="w-4 h-4" />
Live Demo
</a>
)}
</div>
</div>
</div>
</div>
);
return (
<div className="min-h-screen font-sans" style={{ backgroundColor: nordColors.bg, color: nordColors.text }}>
{/* Navigation */}
<nav
className="fixed top-0 w-full z-40 transition-all duration-300"
style={{
backgroundColor: scrolled ? 'rgba(46, 52, 64, 0.95)' : 'transparent',
borderBottom: scrolled ? `1px solid ${nordColors.bgLight}` : 'none',
backdropFilter: scrolled ? 'blur(10px)' : 'none'
}}
>
<div className="max-w-6xl mx-auto px-6 py-4">
<div className="flex justify-between items-center">
<div className="text-xl font-bold" style={{ color: nordColors.accent }}>
Pietro Quintavalle
</div>
<div className="hidden md:flex space-x-8">
{['Home', 'About', 'Projects', 'Education', 'Contact'].map((item: string) => (
<button
key={item}
onClick={() => scrollToSection(item.toLowerCase())}
className="transition-colors hover:opacity-80"
style={{
color: activeSection === item.toLowerCase() ? nordColors.accent : nordColors.text
}}
>
{item}
</button>
))}
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<section id="home" className="min-h-screen flex items-center justify-center relative">
<div className="text-center max-w-4xl mx-auto px-6">
<div className="mb-8">
<h1 className="text-5xl md:text-7xl font-bold mb-4">
<span style={{ color: nordColors.text }}>Pietro</span>
<span className="ml-4" style={{ color: nordColors.accent }}>Quintavalle</span>
</h1>
<div className="text-xl md:text-2xl mb-6" style={{ color: nordColors.blue }}>
Economics Student • Data Analytics • Web Development
</div>
<p className="text-lg max-w-2xl mx-auto leading-relaxed" style={{ color: nordColors.textMuted }}>
Passionate about the intersection of economics and technology, exploring how data and code can solve real-world problems.
</p>
</div>
<div className="flex justify-center space-x-6 mb-12">
<a
href="https://github.com/Quinta0"
className="p-3 rounded-full transition-colors hover:opacity-80"
style={{ backgroundColor: nordColors.bgLight }}
target="_blank"
rel="noopener noreferrer"
>
<Github className="w-6 h-6" style={{ color: nordColors.text }} />
</a>
<a
href="https://www.linkedin.com/in/pietro-quintavalle-996b96267/"
className="p-3 rounded-full transition-colors hover:opacity-80"
style={{ backgroundColor: nordColors.bgLight }}
target="_blank"
rel="noopener noreferrer"
>
<Linkedin className="w-6 h-6" style={{ color: nordColors.text }} />
</a>
<a
href="mailto:0pietroquintavalle0@gmail.com"
className="p-3 rounded-full transition-colors hover:opacity-80"
style={{ backgroundColor: nordColors.bgLight }}
>
<Mail className="w-6 h-6" style={{ color: nordColors.text }} />
</a>
<a
href="https://quinta0.github.io/whoami/"
className="p-3 rounded-full transition-colors hover:opacity-80"
style={{ backgroundColor: nordColors.bgLight }}
target="_blank"
rel="noopener noreferrer"
>
<Globe className="w-6 h-6" style={{ color: nordColors.text }} />
</a>
</div>
</div>
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
<ChevronDown className="w-6 h-6" style={{ color: nordColors.blue }} />
</div>
</section>
{/* About Section */}
<section id="about" className="py-20" style={{ backgroundColor: 'rgba(59, 66, 82, 0.3)' }}>
<div className="max-w-6xl mx-auto px-6">
<h2 className="text-4xl font-bold text-center mb-16" style={{ color: nordColors.accent }}>
About Me
</h2>
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<p className="text-lg leading-relaxed mb-6" style={{ color: nordColors.text }}>
I'm an Economics student at USI with extensive experience in data analytics and web development.
My journey combines analytical thinking with technical skills to create meaningful solutions.
</p>
<p className="text-lg leading-relaxed mb-6" style={{ color: nordColors.text }}>
Google-certified in Data Analytics and IT Support, I bring proven expertise in Python, R, SQL,
and modern web technologies like Next.js and React.
</p>
<div className="flex flex-wrap gap-3">
<span
className="px-3 py-1 rounded-full text-sm"
style={{ backgroundColor: nordColors.darkBlue, color: nordColors.text }}
>
Italian (Native)
</span>
<span
className="px-3 py-1 rounded-full text-sm"
style={{ backgroundColor: nordColors.darkBlue, color: nordColors.text }}
>
English (C2)
</span>
<span
className="px-3 py-1 rounded-full text-sm"
style={{ backgroundColor: nordColors.darkBlue, color: nordColors.text }}
>
Switzerland 🇨🇭
</span>
</div>
</div>
<div className="space-y-6">
{Object.entries(skills).map(([category, skillList]: [string, string[]]) => (
<div
key={category}
className="p-6 rounded-lg border"
style={{
backgroundColor: nordColors.bg,
borderColor: nordColors.bgLight
}}
>
<h3 className="text-xl font-semibold mb-3" style={{ color: nordColors.blue }}>
{category}
</h3>
<div className="flex flex-wrap gap-2">
{skillList.map((skill: string) => (
<span
key={skill}
className="px-3 py-1 rounded text-sm"
style={{
backgroundColor: nordColors.bgLighter,
color: nordColors.text
}}
>
{skill}
</span>
))}
</div>
</div>
))}
</div>
</div>
</div>
</section>
{/* Projects Section */}
<section id="projects" className="py-20">
<div className="max-w-6xl mx-auto px-6">
<h2 className="text-4xl font-bold text-center mb-16" style={{ color: nordColors.accent }}>
Featured Projects
</h2>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{projects.map((project: Project, index: number) => (
<div
key={index}
className="p-6 rounded-lg border transition-colors hover:opacity-90 cursor-pointer"
style={{
backgroundColor: nordColors.bgLight,
borderColor: nordColors.bgLighter
}}
onClick={() => setShowProjectGallery(project)}
>
<div className="flex items-center mb-4">
<div
className="p-2 rounded-lg mr-3"
style={{ backgroundColor: nordColors.darkBlue }}
>
{project.icon}
</div>
<div>
<h3 className="text-xl font-semibold" style={{ color: nordColors.text }}>
{project.title}
</h3>
<p className="text-sm" style={{ color: nordColors.blue }}>
{project.period}
</p>
</div>
</div>
<p className="leading-relaxed mb-4" style={{ color: nordColors.textMuted }}>
{project.description}
</p>
<div className="flex flex-wrap gap-2 mb-4">
{project.tech.map((tech: string) => (
<span
key={tech}
className="px-2 py-1 rounded text-xs"
style={{
backgroundColor: nordColors.bg,
color: nordColors.accent
}}
>
{tech}
</span>
))}
</div>
<div className="flex items-center justify-between">
<span
className="text-sm flex items-center gap-2"
style={{ color: nordColors.blue }}
>
<Image className="w-4 h-4" />
View Gallery
</span>
<span
className="px-2 py-1 rounded text-xs"
style={{
backgroundColor: nordColors.darkBlue,
color: nordColors.text
}}
>
{project.status}
</span>
</div>
</div>
))}
</div>
</div>
</section>
{/* Education & Certificates Section */}
<section id="education" className="py-20" style={{ backgroundColor: 'rgba(59, 66, 82, 0.3)' }}>
<div className="max-w-6xl mx-auto px-6">
<h2 className="text-4xl font-bold text-center mb-16" style={{ color: nordColors.accent }}>
Education & Certifications
</h2>
<div className="grid md:grid-cols-2 gap-12">
{/* Education */}
<div>
<h3 className="text-2xl font-semibold mb-8 flex items-center" style={{ color: nordColors.blue }}>
<GraduationCap className="w-6 h-6 mr-2" />
Education
</h3>
<div className="space-y-6">
{education.map((edu: Education, index: number) => (
<div
key={index}
className="p-6 rounded-lg border"
style={{
backgroundColor: nordColors.bg,
borderColor: nordColors.bgLight
}}
>
<h4 className="text-lg font-semibold mb-2" style={{ color: nordColors.text }}>
{edu.degree}
</h4>
<p className="mb-2" style={{ color: nordColors.blue }}>
{edu.institution}
</p>
<div className="flex justify-between items-center">
<span className="text-sm" style={{ color: nordColors.textMuted }}>
{edu.period}
</span>
<span
className="px-2 py-1 rounded text-xs"
style={{ backgroundColor: nordColors.darkBlue, color: nordColors.text }}
>
{edu.status}
</span>
</div>
</div>
))}
</div>
</div>
{/* Certificates */}
<div>
<h3 className="text-2xl font-semibold mb-8 flex items-center" style={{ color: nordColors.blue }}>
<Award className="w-6 h-6 mr-2" />
Certifications
</h3>
<div className="space-y-4">
{certificates.map((cert: string, index: number) => (
<div
key={index}
className="p-4 rounded-lg border flex items-center"
style={{
backgroundColor: nordColors.bg,
borderColor: nordColors.bgLight
}}
>
<div
className="w-2 h-2 rounded-full mr-4"
style={{ backgroundColor: nordColors.accent }}
></div>
<span style={{ color: nordColors.text }}>{cert}</span>
</div>
))}
</div>
<div
className="mt-8 p-6 rounded-lg border"
style={{
backgroundColor: nordColors.bg,
borderColor: nordColors.bgLight
}}
>
<h4 className="text-lg font-semibold mb-3" style={{ color: nordColors.blue }}>
Interests
</h4>
<div className="flex flex-wrap gap-2">
{['Computer Hardware', 'Web Development', 'Finance', 'Economics', 'Literature'].map((interest: string) => (
<span
key={interest}
className="px-3 py-1 rounded text-sm"
style={{
backgroundColor: nordColors.bgLighter,
color: nordColors.text
}}
>
{interest}
</span>
))}
</div>
</div>
</div>
</div>
</div>
</section>
{/* Contact Section */}
<section id="contact" className="py-20">
<div className="max-w-4xl mx-auto px-6 text-center">
<h2 className="text-4xl font-bold mb-8" style={{ color: nordColors.accent }}>
Let's Connect
</h2>
<p className="text-lg mb-12 max-w-2xl mx-auto" style={{ color: nordColors.textMuted }}>
I'm always interested in discussing new opportunities, collaborations, or just having a chat about technology and economics.
</p>
<div className="grid md:grid-cols-3 gap-8 mb-12">
<a
href="mailto:0pietroquintavalle0@gmail.com"
className="p-6 rounded-lg transition-colors hover:opacity-80 group"
style={{ backgroundColor: nordColors.bgLight }}
>
<Mail className="w-8 h-8 mx-auto mb-4" style={{ color: nordColors.blue }} />
<h3 className="text-lg font-semibold mb-2" style={{ color: nordColors.text }}>Email</h3>
<p className="text-sm" style={{ color: nordColors.textMuted }}>Drop me a line</p>
</a>
<a
href="https://www.linkedin.com/in/pietro-quintavalle-996b96267/"
className="p-6 rounded-lg transition-colors hover:opacity-80 group"
style={{ backgroundColor: nordColors.bgLight }}
target="_blank"
rel="noopener noreferrer"
>
<Linkedin className="w-8 h-8 mx-auto mb-4" style={{ color: nordColors.blue }} />
<h3 className="text-lg font-semibold mb-2" style={{ color: nordColors.text }}>LinkedIn</h3>
<p className="text-sm" style={{ color: nordColors.textMuted }}>Let's connect professionally</p>
</a>
<a
href="https://github.com/Quinta0"
className="p-6 rounded-lg transition-colors hover:opacity-80 group"
style={{ backgroundColor: nordColors.bgLight }}
target="_blank"
rel="noopener noreferrer"
>
<Github className="w-8 h-8 mx-auto mb-4" style={{ color: nordColors.blue }} />
<h3 className="text-lg font-semibold mb-2" style={{ color: nordColors.text }}>GitHub</h3>
<p className="text-sm" style={{ color: nordColors.textMuted }}>Check out my code</p>
</a>
</div>
</div>
</section>
{/* Footer */}
<footer className="py-8 border-t" style={{ borderColor: nordColors.bgLight }}>
<div className="max-w-6xl mx-auto px-6 text-center">
<p style={{ color: nordColors.textMuted }}>
Built with ❄️ using Next.js & Nord theme • Pietro Quintavalle © 2025
</p>
</div>
</footer>
{/* Project Gallery Modal */}
{showProjectGallery && (
<ProjectGallery
project={showProjectGallery}
onClose={() => setShowProjectGallery(false)}
/>
)}
</div>
);
};
export default Portfolio;
-36
View File
@@ -1,36 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
-56
View File
@@ -1,56 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
-79
View File
@@ -1,79 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
-24
View File
@@ -1,24 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
-23
View File
@@ -1,23 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }
+23
View File
@@ -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>
-31
View File
@@ -1,31 +0,0 @@
export const NORD_COLORS: NordColors = {
bg: '#2E3440',
bgLight: '#3B4252',
bgLighter: '#434C5E',
bgLightest: '#4C566A',
text: '#D8DEE9',
textMuted: 'rgba(216, 222, 233, 0.6)',
accent: '#88C0D0',
blue: '#81A1C1',
darkBlue: '#5E81AC',
cyan: '#8FBCBB',
green: '#A3BE8C',
yellow: '#EBCB8B',
orange: '#D08770',
red: '#BF616A',
purple: '#B48EAD'
};
export const SKILLS: Record<string, string[]> = {
'Data & Analytics': ['Python', 'R', 'SQL', 'Statistical Analysis', 'Data Visualization', 'Business Intelligence'],
'Web Development': ['JavaScript', 'TypeScript', 'React', 'Next.js', 'HTML5', 'CSS3', 'Tailwind CSS'],
'Backend & Tools': ['Flask', 'Express', 'MongoDB', 'SQLite', 'Git', 'Linux'],
'Other': ['Java', 'LaTeX', 'Project Planning', 'Financial Analysis']
};
export const CERTIFICATES: string[] = [
'Google Data Analytics Professional Certificate',
'Google IT Support Professional Certificate',
'IT Security: Defense against the digital dark arts',
'Python for Data Science, AI & Development'
];
-6
View File
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
-12
View File
@@ -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
+1010
View File
File diff suppressed because it is too large Load Diff
+10 -18
View File
@@ -2,32 +2,24 @@
"name": "pietro-portfolio",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.441.0",
"next": "14.2.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7"
"three": "^0.186.0"
},
"devDependencies": {
"@types/node": "^20.16.5",
"@types/react": "^18.3.8",
"@types/react-dom": "^18.3.0",
"eslint": "^8.57.1",
"eslint-config-next": "14.2.13",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.12",
"typescript": "^5.6.2"
"@types/three": "^0.186.0",
"@vitejs/plugin-react": "^6.0.5",
"typescript": "^5.6.2",
"vite": "^8.2.1"
}
}
}
-8
View File
@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

+173
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+10
View File
@@ -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>,
)
+177
View File
@@ -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" />
}
+5
View File
@@ -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 }
+64
View File
@@ -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);
}`;
+142
View File
@@ -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)); }
+95
View File
@@ -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())
}, [])
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+799
View File
@@ -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>
-42
View File
@@ -1,42 +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',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
}
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;
+10 -23
View File
@@ -1,30 +1,17 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/types/*": ["./src/types/*"]
}
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"types": ["vite/client"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
"include": ["src", "vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
base: '/whoami/',
plugins: [react()],
})