Compare commits
14 Commits
f2a155c947
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d0a0c9626 | |||
| af3d331114 | |||
| 611af1ac67 | |||
| 4946d2586c | |||
| 5db1b69090 | |||
| 6725a4accd | |||
| deb3b8b1d2 | |||
| 0001b06d38 | |||
| 5b65c52726 | |||
| 58782c95cd | |||
| 4c4a7e6fba | |||
| d1cc9de1f1 | |||
| fa37f0358d | |||
| 15bc86b5a6 |
@@ -0,0 +1,37 @@
|
||||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-and-deploy:
|
||||
runs-on: host
|
||||
|
||||
steps:
|
||||
- name: Install Node.js requirements for Actions
|
||||
run: |
|
||||
if ! command -v node &> /dev/null; then
|
||||
apk add --no-cache nodejs npm
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Build website
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to Apache volume
|
||||
run: |
|
||||
find /var/www/louis-potevin.dev -mindepth 1 -delete
|
||||
|
||||
cp -r dist/. /var/www/louis-potevin.dev/
|
||||
|
||||
chmod -R 755 /var/www/louis-potevin.dev/
|
||||
+3
-137
@@ -1,138 +1,4 @@
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
dist/
|
||||
.astro/
|
||||
.DS_Store
|
||||
|
||||
@@ -1,2 +1,107 @@
|
||||
# portfolio
|
||||
# louis-potevin.dev — Portfolio v2
|
||||
|
||||
Portfolio bilingue (FR/EN) construit avec **Astro 7**, **Nova Design System**, **Three.js** et **Lenis**.
|
||||
Accueil = un « voyage » : le scroll ne fait jamais défiler une page, il pilote une caméra 3D
|
||||
et l'animation continue. Chaque projet a sa propre scène sur-mesure.
|
||||
|
||||
## Stack
|
||||
|
||||
- [Astro 7](https://astro.build) — SSG, i18n natif, zéro JS par défaut
|
||||
- [`@unkn0wndo3s/nova-design-system`](https://www.npmjs.com/package/@unkn0wndo3s/nova-design-system) — composants + tokens (source unique du design)
|
||||
- [Three.js](https://threejs.org) — le moteur du voyage (accueil)
|
||||
- [Lenis](https://lenis.darkroom.engineering) — smooth scroll ; toutes les animations lisent le scroll depuis Lenis, d'où la sensation d'une seule courbe de mouvement
|
||||
- Sass — les mixins typographiques NDS sont exposés via un `loadPath` (voir `astro.config.mjs`)
|
||||
|
||||
## Démarrer
|
||||
|
||||
```bash
|
||||
npm install # .npmrc force legacy-peer-deps (requis par @lucide/astro avec Astro 7)
|
||||
npm run dev # http://localhost:4321
|
||||
npm run build # sortie statique dans dist/
|
||||
npm run check # astro check (TS + diagnostics) - 0 erreur attendu
|
||||
```
|
||||
|
||||
Node >= 22.12 (contrainte du NDS).
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── data/ site.ts (profil, liens), projects.ts (projets localisés + stages du showcase NDS)
|
||||
├── i18n/ index.ts (helpers locale/chemins/hreflang), ui.ts (dictionnaires FR/EN)
|
||||
├── lib/seo.ts JSON-LD : Person, WebSite, BreadcrumbList, SoftwareSourceCode, ProfilePage
|
||||
├── lib/smooth.ts Singleton Lenis · lib/scrub.ts - progression 0..1 des scènes épinglées
|
||||
├── layouts/ BaseLayout.astro (head SEO complet, nav, footer, init Lenis)
|
||||
├── components/
|
||||
│ ├── scene/ voyage.ts - moteur de l'accueil · scene.ts - fond ambiant des sous-pages
|
||||
│ ├── scenes/ InfraScene, BlueprintScene, SorterScene - scènes projet sur-mesure
|
||||
│ └── showcase/ ComponentShowcase.astro - composants NDS "volants" (page projet NDS)
|
||||
├── views/ La logique de chaque page (partagée entre locales)
|
||||
└── pages/ Routes minces : / (FR, défaut) et /en/* (miroirs)
|
||||
```
|
||||
|
||||
**Règle i18n** : une page = une view. Les routes (`pages/` et `pages/en/`) ne font
|
||||
qu'importer la view ; la locale est déduite de l'URL (`getLocaleFromUrl`).
|
||||
`/` = français (cible : recruteurs FR), `/en/` = anglais. Pour inverser, changer
|
||||
`DEFAULT_LOCALE` dans `src/i18n/index.ts` **et** `i18n.defaultLocale` dans `astro.config.mjs`.
|
||||
|
||||
## Le voyage (accueil)
|
||||
|
||||
`voyage.ts` transforme la hauteur de scroll en **timeline** : les sections HTML sont des
|
||||
overlays fixes, et le scroll avance une caméra sur une courbe Catmull-Rom à travers
|
||||
nébuleuses, ceinture d'astéroïdes (une balise lumineuse par projet) et portail final.
|
||||
|
||||
- **Scroll down** → les astéroïdes dérivent et se percutent (collisions élastiques,
|
||||
étincelles additives).
|
||||
- **Scroll up** → **vortex gravitationnel** : spirale vers la formation d'origine.
|
||||
- Barre de progression + points de navigation latéraux (clic = seek animé).
|
||||
- Les ancres (`/#about`, `/#contact`…) deviennent des seeks sur la timeline.
|
||||
- Progressive enhancement : le même markup rend une page normale empilée sans JS,
|
||||
sans WebGL ou avec `prefers-reduced-motion` (la classe `html.cinema` n'est jamais posée).
|
||||
|
||||
## Métadonnées NDS toujours à jour
|
||||
|
||||
`src/lib/ndsMeta.ts` : la liste des composants (et leur nombre) est parsée depuis
|
||||
l'index d'exports du paquet installé, et la version est récupérée sur le registre
|
||||
npm **au moment du build** (fallback : version installée si hors-ligne). La forge,
|
||||
les textes de la home et la fiche projet NDS lisent tous cette source — plus aucun
|
||||
chiffre en dur à maintenir.
|
||||
|
||||
## /docs — la documentation technique
|
||||
|
||||
Une page dédiée (`/docs`, FR/EN) documente tout le fonctionnement du site en 13 chapitres,
|
||||
avec sommaire sticky (scrollspy) et snippets **extraits du vrai code source au build**
|
||||
(imports Vite `?raw` + découpe par marqueurs — si un marqueur disparaît, le build échoue
|
||||
au lieu de publier une doc périmée). Coloration syntaxique Shiki via `astro:components`.
|
||||
|
||||
## Scènes projet sur-mesure
|
||||
|
||||
Toutes épinglées (section haute + stage sticky), pilotées par `scrub.ts`, fallback statique :
|
||||
|
||||
| Projet | Scène |
|
||||
| --- | --- |
|
||||
| nova-design-system | `ComponentShowcase` (« la forge ») — 3 actes : les tokens (vrais swatches) convergent, 8 vrais composants NDS défilent dans un tunnel 3D avec specs + snippet d'usage, la bibliothèque se fige en grille avec compteurs |
|
||||
| nova-infra | `InfraScene` — le graphe Visiteur → Cloudflare → Apache/VPS se dessine au scroll, puis un paquet circule en continu |
|
||||
| portfolio | `BlueprintScene` — le wireframe du site se trace (dashoffset) puis s'allume aux couleurs NDS |
|
||||
| file-organizer | `SorterScene` — 20 fichiers éparpillés (seed déterministe) se rangent en colonnes au scroll |
|
||||
|
||||
## SEO
|
||||
|
||||
- `hreflang` fr / en / x-default + canonical sur chaque page
|
||||
- JSON-LD : `Person` (@id partagé), `WebSite`, `BreadcrumbList` par page,
|
||||
`SoftwareSourceCode` par projet, `ProfilePage` sur /about
|
||||
- Open Graph + Twitter cards localisés (`og-fr.png` / `og-en.png`)
|
||||
- Sitemap i18n (`@astrojs/sitemap`) + `robots.txt`
|
||||
- Pas de meta `keywords` (ignorées par Google, signal spam pour certains crawlers)
|
||||
|
||||
## À faire côté déploiement
|
||||
|
||||
1. **Désactiver « Email Address Obfuscation »** dans Cloudflare (Scrape Shield) :
|
||||
sinon CF injecte `email-decode.min.js`, casse le mailto sans JS et affiche
|
||||
« [email protected] » aux crawlers de recruteurs.
|
||||
2. **`public/og-fr.png` et `public/og-en.png`** (1200×630) — visuels de partage.
|
||||
2. Brancher le workflow **Gitea Actions** existant (`npm ci && npm run build`, publier `dist/`).
|
||||
3. `/about` et `/contact` redirigent vers `/#about` et `/#contact` (config Astro) —
|
||||
les anciennes URL indexées ne cassent pas.
|
||||
4. Tester le voyage sur un vrai GPU/trackpad et ajuster les poids `data-weight`
|
||||
des sections dans `HomeView.astro` (durée relative de chaque étape).
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* NDS's _typography.scss ships a Google Fonts `@import` which lands mid-file
|
||||
* in the bundled CSS - invalid per spec (W3C error) and a hidden render
|
||||
* blocker. The fonts are loaded via <link> in BaseLayout instead, and this
|
||||
* plugin strips the stray @import from every emitted CSS asset.
|
||||
*/
|
||||
const stripFontImport = {
|
||||
name: 'strip-google-fonts-import',
|
||||
generateBundle(_, bundle) {
|
||||
for (const chunk of Object.values(bundle)) {
|
||||
if (chunk.type === 'asset' && chunk.fileName.endsWith('.css') && typeof chunk.source === 'string') {
|
||||
chunk.source = chunk.source.replace(
|
||||
/@import url\((['"]?)https:\/\/fonts\.googleapis\.com[^)]*\);?/g,
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const ndsTokens = fileURLToPath(
|
||||
new URL('./node_modules/@unkn0wndo3s/nova-design-system/src/styles/tokens', import.meta.url),
|
||||
);
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://louis-potevin.dev',
|
||||
trailingSlash: 'ignore',
|
||||
|
||||
// About & Contact now live on the home timeline.
|
||||
redirects: {
|
||||
'/about': '/#about',
|
||||
'/contact': '/#contact',
|
||||
'/en/about': '/en/#about',
|
||||
'/en/contact': '/en/#contact',
|
||||
},
|
||||
|
||||
i18n: {
|
||||
defaultLocale: 'fr',
|
||||
locales: ['fr', 'en'],
|
||||
routing: {
|
||||
prefixDefaultLocale: false,
|
||||
},
|
||||
},
|
||||
|
||||
integrations: [
|
||||
sitemap({
|
||||
i18n: {
|
||||
defaultLocale: 'fr',
|
||||
locales: { fr: 'fr-FR', en: 'en-US' },
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
vite: {
|
||||
plugins: [stripFontImport],
|
||||
build: {
|
||||
// Source maps help debugging in prod and satisfy Lighthouse's audit.
|
||||
sourcemap: true,
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
loadPaths: [ndsTokens],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+5645
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "louis-potevin.dev",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Portfolio of Louis Potevin - full-stack developer. Astro + TypeScript + Sass, powered by Nova Design System.",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/sitemap": "^3.7.3",
|
||||
"@lucide/astro": "^1.23.0",
|
||||
"@types/three": "^0.185.0",
|
||||
"@unkn0wndo3s/nova-design-system": "^1.2.1",
|
||||
"astro": "^7.0.5",
|
||||
"lenis": "^1.3.25",
|
||||
"sass": "^1.101.0",
|
||||
"simple-icons-astro": "^16.1.0",
|
||||
"three": "^0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.9",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<!-- Nova mark: a four-point spark on the NDS dark background. -->
|
||||
<rect width="64" height="64" rx="14" fill="#040B0B"/>
|
||||
<path d="M32 8 L38.5 25.5 L56 32 L38.5 38.5 L32 56 L25.5 38.5 L8 32 L25.5 25.5 Z" fill="#2EFAFA"/>
|
||||
<circle cx="47" cy="15" r="3.4" fill="#34C2C9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 345 B |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://louis-potevin.dev/sitemap-index.xml
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
/**
|
||||
* Minimal, valid brand icon: one fill, no <title>, decorative by default.
|
||||
* See src/lib/brandIcons.ts for why the simple-icons components aren't
|
||||
* used directly.
|
||||
*/
|
||||
import { BRAND_ICONS } from '../lib/brandIcons';
|
||||
|
||||
export interface Props {
|
||||
icon: string;
|
||||
size?: number;
|
||||
/** 'brand' uses the official color; 'current' inherits text color. */
|
||||
color?: 'brand' | 'current';
|
||||
}
|
||||
|
||||
const { icon, size = 18, color = 'brand' } = Astro.props;
|
||||
const data = BRAND_ICONS[icon];
|
||||
---
|
||||
|
||||
{data && (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill={color === 'brand' ? data.brand : 'currentColor'}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={data.d} />
|
||||
</svg>
|
||||
)}
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
/**
|
||||
* LinkedIn glyph, provided locally: neither lucide nor simple-icons
|
||||
* distributes it anymore (trademark policy).
|
||||
*/
|
||||
export interface Props { size?: number }
|
||||
const { size = 20 } = Astro.props;
|
||||
---
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20.45 20.45h-3.55v-5.57c0-1.33-.03-3.04-1.85-3.04-1.86 0-2.14 1.45-2.14 2.94v5.67H9.35V9h3.41v1.56h.05c.47-.9 1.63-1.85 3.36-1.85 3.6 0 4.27 2.37 4.27 5.46v6.28zM5.34 7.43a2.06 2.06 0 1 1 0-4.12 2.06 2.06 0 0 1 0 4.12zM7.12 20.45H3.55V9h3.57v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.72v20.55C0 23.23.79 24 1.77 24h20.45c.98 0 1.78-.77 1.78-1.73V1.72C24 .77 23.2 0 22.22 0z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
export interface Props {
|
||||
index?: string;
|
||||
}
|
||||
const { index } = Astro.props;
|
||||
---
|
||||
|
||||
<p class="eyebrow">
|
||||
{index && <span class="eyebrow__index" aria-hidden="true">{index}</span>}
|
||||
<span class="eyebrow__label"><slot /></span>
|
||||
</p>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.eyebrow {
|
||||
@include type.text-label;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--nds-spacing-xs);
|
||||
color: var(--nds-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.eyebrow__index {
|
||||
font-family: var(--nds-font-mono);
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
import { Badge, Button } from '@unkn0wndo3s/nova-design-system';
|
||||
import TechIcon from './TechIcon.astro';
|
||||
import type { Project } from '../data/projects';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
|
||||
export interface Props {
|
||||
project: Project;
|
||||
index?: number;
|
||||
headingLevel?: 'h2' | 'h3';
|
||||
}
|
||||
|
||||
const { project, index, headingLevel: Heading = 'h3' } = Astro.props;
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const statusType = { live: 'success', maintained: 'info', wip: 'warning' } as const;
|
||||
const statusLabel = {
|
||||
live: t('work.status.live'),
|
||||
maintained: t('work.status.maintained'),
|
||||
wip: t('work.status.wip'),
|
||||
} as const;
|
||||
|
||||
const href = localizePath(`/work/${project.slug}`, locale);
|
||||
---
|
||||
|
||||
<article class="project-card">
|
||||
<header class="project-card__head">
|
||||
{typeof index === 'number' && (
|
||||
<span class="project-card__index" aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
|
||||
)}
|
||||
<Badge type={statusType[project.status]}>{statusLabel[project.status]}</Badge>
|
||||
</header>
|
||||
|
||||
<Heading class="project-card__title">
|
||||
<a href={href}>{project.name}</a>
|
||||
</Heading>
|
||||
<p class="project-card__tagline">{project.tagline[locale]}</p>
|
||||
<p class="project-card__summary">{project.summary[locale]}</p>
|
||||
|
||||
<ul class="project-card__stack" aria-label={t('work.stack')}>
|
||||
{project.stack.map((tech) => (
|
||||
<li><TechIcon name={tech.name} icon={tech.icon} size={16} /></li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<footer class="project-card__footer">
|
||||
<Button href={href} type="secondary" size="sm">{t('home.projects.view')}</Button>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.project-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-sm);
|
||||
height: 100%;
|
||||
padding: clamp(20px, 3vw, 32px);
|
||||
background: color-mix(in srgb, var(--nds-surface) 88%, transparent);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-xl);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--nds-border-strong);
|
||||
box-shadow: var(--nds-shadow-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.project-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.project-card__index {
|
||||
font-family: var(--nds-font-mono);
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.project-card__title {
|
||||
@include type.text-2xl;
|
||||
|
||||
a {
|
||||
color: var(--nds-text);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { color: var(--nds-primary); }
|
||||
}
|
||||
}
|
||||
|
||||
.project-card__tagline {
|
||||
@include type.text-lg;
|
||||
color: var(--nds-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-card__summary {
|
||||
@include type.text-base;
|
||||
color: var(--nds-neutral);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.project-card__stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.project-card__footer { margin-top: var(--nds-spacing-xs); }
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
import BrandIcon from './BrandIcon.astro';
|
||||
import BrandLinkedin from './BrandLinkedin.astro';
|
||||
import { site } from '../data/site';
|
||||
import { getLocaleFromUrl, useTranslations } from '../i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer__inner">
|
||||
<div>
|
||||
<p class="footer__name">{site.name}</p>
|
||||
<p class="footer__role">{t('footer.role')}</p>
|
||||
</div>
|
||||
|
||||
<nav class="footer__links" aria-label="Social">
|
||||
<a href={site.links.gitea.url} title="Gitea" rel="me noopener" target="_blank">
|
||||
<BrandIcon icon="Gitea" size={20} color="current" /><span class="sr-only">Gitea</span>
|
||||
</a>
|
||||
<a href={site.links.github.url} title="GitHub" rel="me noopener" target="_blank">
|
||||
<BrandIcon icon="Github" size={20} color="current" /><span class="sr-only">GitHub</span>
|
||||
</a>
|
||||
<a href={site.links.npm.url} title="npm" rel="me noopener" target="_blank">
|
||||
<BrandIcon icon="Npm" size={20} color="current" /><span class="sr-only">npm</span>
|
||||
</a>
|
||||
<a href={site.links.linkedin.url} title="LinkedIn" rel="me noopener" target="_blank">
|
||||
<BrandLinkedin size={20} /><span class="sr-only">LinkedIn</span>
|
||||
</a>
|
||||
<a href={`mailto:${site.email}`} title="E-mail">{t('contact.email.label')}</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<p class="footer__legal">© {year} {site.name} · {t('footer.madeWith')}</p>
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.footer {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
margin-top: clamp(48px, 8vh, 96px);
|
||||
padding: var(--nds-spacing-2xl) clamp(20px, 5vw, 56px) var(--nds-spacing-xl);
|
||||
border-top: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
background: color-mix(in srgb, var(--nds-surface) 60%, transparent);
|
||||
}
|
||||
|
||||
.footer__inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--nds-spacing-lg);
|
||||
max-width: 1200px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.footer__name { @include type.text-xl; }
|
||||
.footer__role {
|
||||
@include type.text-base;
|
||||
color: var(--nds-neutral);
|
||||
margin-top: var(--nds-spacing-2xs);
|
||||
}
|
||||
|
||||
.footer__links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-md);
|
||||
|
||||
a {
|
||||
@include type.text-label;
|
||||
display: inline-flex;
|
||||
color: var(--nds-neutral);
|
||||
transition: color 0.15s ease;
|
||||
&:hover { color: var(--nds-primary); }
|
||||
}
|
||||
}
|
||||
|
||||
.footer__legal {
|
||||
@include type.text-sm;
|
||||
max-width: 1200px;
|
||||
margin: var(--nds-spacing-lg) auto 0;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
margin: -1px; padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
---
|
||||
import { Button } from '@unkn0wndo3s/nova-design-system';
|
||||
import {
|
||||
getLocaleFromUrl,
|
||||
useTranslations,
|
||||
localizePath,
|
||||
unlocalizePath,
|
||||
LOCALES,
|
||||
} from '../i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
const route = unlocalizePath(Astro.url.pathname);
|
||||
|
||||
// Only real pages in the main nav - no surprise anchors. Contact lives in
|
||||
// the CTA button (and on the home timeline); the brand links home.
|
||||
const links = [
|
||||
{ href: localizePath('/work', locale), label: t('nav.work'), match: '/work' },
|
||||
{ href: localizePath('/docs', locale), label: t('nav.docs'), match: '/docs' },
|
||||
];
|
||||
|
||||
const isActive = (match: string) =>
|
||||
match === '/' ? route === '/' : route === match || route.startsWith(`${match}/`);
|
||||
---
|
||||
|
||||
<header class="nav" data-nav>
|
||||
<a class="nav__brand" href={localizePath('/', locale)} aria-label={t('nav.home')}>
|
||||
L<span aria-hidden="true"> · </span>POTEVIN
|
||||
</a>
|
||||
|
||||
<nav class="nav__links" aria-label="Main">
|
||||
{links.map((l) => (
|
||||
<a href={l.href} aria-current={isActive(l.match) ? 'page' : undefined}>{l.label}</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div class="nav__right">
|
||||
<nav class="nav__lang" aria-label={t('a11y.langSwitch')}>
|
||||
{LOCALES.map((l, i) => (
|
||||
<>
|
||||
{i > 0 && <span aria-hidden="true">/</span>}
|
||||
<a
|
||||
href={localizePath(route, l)}
|
||||
hreflang={l}
|
||||
aria-current={l === locale ? 'true' : undefined}
|
||||
>
|
||||
{l.toUpperCase()}
|
||||
</a>
|
||||
</>
|
||||
))}
|
||||
</nav>
|
||||
<span class="nav__cta">
|
||||
<Button href={localizePath('/#contact', locale)} size="sm">{t('nav.cta')}</Button>
|
||||
</span>
|
||||
<button class="nav__burger" data-nav-toggle aria-expanded="false" aria-label="Menu">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-lg);
|
||||
padding: var(--nds-spacing-sm) clamp(20px, 5vw, 56px);
|
||||
background: color-mix(in srgb, var(--nds-background) 78%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
}
|
||||
|
||||
.nav__brand {
|
||||
@include type.text-xl;
|
||||
color: var(--nds-text);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.08em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav__links {
|
||||
display: flex;
|
||||
gap: var(--nds-spacing-lg);
|
||||
margin-inline: auto;
|
||||
|
||||
a {
|
||||
@include type.text-label;
|
||||
color: var(--nds-neutral);
|
||||
text-decoration: none;
|
||||
padding-block: var(--nds-spacing-2xs);
|
||||
border-bottom: var(--nds-border-width-medium) solid transparent;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover { color: var(--nds-text); }
|
||||
&[aria-current='page'] {
|
||||
color: var(--nds-primary);
|
||||
border-bottom-color: var(--nds-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-md);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.nav__lang {
|
||||
@include type.text-label;
|
||||
display: flex;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
color: var(--nds-neutral);
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
&:hover { color: var(--nds-text); }
|
||||
&[aria-current='true'] { color: var(--nds-primary); }
|
||||
}
|
||||
}
|
||||
|
||||
.nav__burger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: var(--nds-spacing-xs);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
span {
|
||||
width: 22px;
|
||||
height: 2px;
|
||||
background: var(--nds-text);
|
||||
border-radius: var(--nds-radius-full);
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
&[aria-expanded='true'] {
|
||||
span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
|
||||
span:nth-child(2) { opacity: 0; }
|
||||
span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.nav { flex-wrap: wrap; }
|
||||
.nav__burger { display: flex; }
|
||||
.nav__cta { display: none; }
|
||||
|
||||
.nav__links {
|
||||
display: none;
|
||||
flex-basis: 100%;
|
||||
flex-direction: column;
|
||||
gap: var(--nds-spacing-sm);
|
||||
padding-block: var(--nds-spacing-md);
|
||||
|
||||
.nav[data-open] & { display: flex; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const nav = document.querySelector('[data-nav]');
|
||||
const toggle = document.querySelector('[data-nav-toggle]');
|
||||
toggle?.addEventListener('click', () => {
|
||||
const open = nav?.toggleAttribute('data-open');
|
||||
toggle.setAttribute('aria-expanded', String(Boolean(open)));
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
/**
|
||||
* Fixed WebGL background. Three.js is imported dynamically so the page
|
||||
* stays interactive before the scene loads; the canvas removes itself
|
||||
* when WebGL is unavailable or reduced motion is preferred.
|
||||
*/
|
||||
---
|
||||
|
||||
<canvas id="space-bg" aria-hidden="true"></canvas>
|
||||
|
||||
<style>
|
||||
#space-bg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('space-bg') as HTMLCanvasElement | null;
|
||||
if (canvas) {
|
||||
import('./scene/scene').then(({ mountSpaceScene }) => mountSpaceScene(canvas));
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
/**
|
||||
* Renders a technology icon (via BrandIcon) with its label. The icon is
|
||||
* decorative: only the visible label is announced, once.
|
||||
*/
|
||||
import BrandIcon from './BrandIcon.astro';
|
||||
|
||||
export interface Props {
|
||||
name: string;
|
||||
icon: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
|
||||
const { name, icon, size = 18 } = Astro.props;
|
||||
---
|
||||
|
||||
<span class="tech">
|
||||
<BrandIcon icon={icon} size={size} />
|
||||
<span>{name}</span>
|
||||
</span>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.tech {
|
||||
@include type.text-label;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-xs);
|
||||
padding: var(--nds-spacing-xs) var(--nds-spacing-sm);
|
||||
color: var(--nds-text);
|
||||
background: var(--nds-surface);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-full);
|
||||
|
||||
:global(svg) { color: var(--nds-primary); flex-shrink: 0; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Space scene - ambient starfield + nebula, plus a scroll-reactive asteroid belt.
|
||||
*
|
||||
* Behavior contract:
|
||||
* - Scrolling DOWN injects energy: asteroids drift apart, collide elastically
|
||||
* and emit spark bursts on impact.
|
||||
* - Scrolling UP does NOT rewind: a gravity vortex wakes up and pulls the
|
||||
* belt into a slow spiral until it settles back into formation.
|
||||
* - No WebGL, `prefers-reduced-motion`, or a lost context -> the canvas is
|
||||
* removed and the site remains fully readable.
|
||||
*
|
||||
* Everything runs on a single fixed canvas, capped at DPR 2, paused when the
|
||||
* tab is hidden.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const ASTEROID_COUNT = 90;
|
||||
const BELT_RADIUS = 26;
|
||||
const BELT_THICKNESS = 9;
|
||||
const ASTEROID_MIN_R = 0.28;
|
||||
const ASTEROID_MAX_R = 1.15;
|
||||
const SPARK_POOL = 240;
|
||||
|
||||
interface AsteroidState {
|
||||
position: THREE.Vector3;
|
||||
velocity: THREE.Vector3;
|
||||
spin: THREE.Vector3;
|
||||
rotation: THREE.Euler;
|
||||
radius: number;
|
||||
/** Slot in the resting formation, used by the vortex to settle back. */
|
||||
home: THREE.Vector3;
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
export function mountSpaceScene(canvas: HTMLCanvasElement): (() => void) | null {
|
||||
if (prefersReducedMotion()) {
|
||||
canvas.remove();
|
||||
return null;
|
||||
}
|
||||
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
} catch {
|
||||
canvas.remove();
|
||||
return null;
|
||||
}
|
||||
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 400);
|
||||
camera.position.set(0, 0, 46);
|
||||
|
||||
scene.add(new THREE.AmbientLight(0x9fdadd, 0.35));
|
||||
const key = new THREE.DirectionalLight(0x7ff5f5, 1.4);
|
||||
key.position.set(18, 22, 30);
|
||||
scene.add(key);
|
||||
|
||||
/* ── Starfield ─────────────────────────────────────────────────────────── */
|
||||
const starGeometry = new THREE.BufferGeometry();
|
||||
const starCount = 1400;
|
||||
const starPositions = new Float32Array(starCount * 3);
|
||||
for (let i = 0; i < starCount; i++) {
|
||||
const r = 90 + Math.random() * 220;
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
starPositions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
starPositions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||
starPositions[i * 3 + 2] = r * Math.cos(phi);
|
||||
}
|
||||
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
|
||||
const stars = new THREE.Points(
|
||||
starGeometry,
|
||||
new THREE.PointsMaterial({ color: 0xbfeeee, size: 0.55, sizeAttenuation: true, transparent: true, opacity: 0.8 }),
|
||||
);
|
||||
scene.add(stars);
|
||||
|
||||
/* ── Nebula (two soft additive sprites) ────────────────────────────────── */
|
||||
const nebulaTexture = makeGlowTexture();
|
||||
for (const [x, y, s, o] of [
|
||||
[-34, 14, 120, 0.10],
|
||||
[30, -20, 150, 0.07],
|
||||
] as const) {
|
||||
const sprite = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: nebulaTexture,
|
||||
color: 0x1fa6ad,
|
||||
transparent: true,
|
||||
opacity: o,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
}),
|
||||
);
|
||||
sprite.position.set(x, y, -60);
|
||||
sprite.scale.setScalar(s);
|
||||
scene.add(sprite);
|
||||
}
|
||||
|
||||
/* ── Asteroid belt (instanced) ─────────────────────────────────────────── */
|
||||
const rockGeometry = makeRockGeometry();
|
||||
const rockMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x2a4040,
|
||||
roughness: 0.92,
|
||||
metalness: 0.12,
|
||||
flatShading: true,
|
||||
});
|
||||
const belt = new THREE.InstancedMesh(rockGeometry, rockMaterial, ASTEROID_COUNT);
|
||||
belt.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
scene.add(belt);
|
||||
|
||||
const asteroids: AsteroidState[] = [];
|
||||
for (let i = 0; i < ASTEROID_COUNT; i++) {
|
||||
const angle = (i / ASTEROID_COUNT) * Math.PI * 2 + Math.random() * 0.35;
|
||||
const radius = BELT_RADIUS + (Math.random() - 0.5) * BELT_THICKNESS;
|
||||
const home = new THREE.Vector3(
|
||||
Math.cos(angle) * radius,
|
||||
(Math.random() - 0.5) * 10,
|
||||
-8 + Math.sin(angle) * 10,
|
||||
);
|
||||
asteroids.push({
|
||||
position: home.clone(),
|
||||
velocity: new THREE.Vector3(),
|
||||
spin: new THREE.Vector3(Math.random(), Math.random(), Math.random()).multiplyScalar(0.4),
|
||||
rotation: new THREE.Euler(Math.random() * Math.PI, Math.random() * Math.PI, 0),
|
||||
radius: ASTEROID_MIN_R + Math.random() * (ASTEROID_MAX_R - ASTEROID_MIN_R),
|
||||
home,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Spark pool for collision impacts ──────────────────────────────────── */
|
||||
const sparkGeometry = new THREE.BufferGeometry();
|
||||
const sparkPositions = new Float32Array(SPARK_POOL * 3);
|
||||
sparkGeometry.setAttribute('position', new THREE.BufferAttribute(sparkPositions, 3));
|
||||
const sparkMaterial = new THREE.PointsMaterial({
|
||||
color: 0x2efafa,
|
||||
size: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const sparks = new THREE.Points(sparkGeometry, sparkMaterial);
|
||||
scene.add(sparks);
|
||||
const sparkVel: THREE.Vector3[] = [];
|
||||
const sparkLife: number[] = [];
|
||||
for (let i = 0; i < SPARK_POOL; i++) {
|
||||
sparkVel.push(new THREE.Vector3());
|
||||
sparkLife.push(0);
|
||||
sparkPositions.set([0, -999, 0], i * 3);
|
||||
}
|
||||
let sparkCursor = 0;
|
||||
|
||||
function burst(at: THREE.Vector3, strength: number) {
|
||||
const n = 10 + Math.floor(strength * 8);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const idx = sparkCursor;
|
||||
sparkCursor = (sparkCursor + 1) % SPARK_POOL;
|
||||
sparkPositions.set([at.x, at.y, at.z], idx * 3);
|
||||
sparkVel[idx]!
|
||||
.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5)
|
||||
.normalize()
|
||||
.multiplyScalar(3 + Math.random() * 5 * strength);
|
||||
sparkLife[idx] = 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Scroll energy model ───────────────────────────────────────────────── */
|
||||
let lastScrollY = window.scrollY;
|
||||
let downEnergy = 0; // fuels drift + collisions
|
||||
let upEnergy = 0; // fuels the vortex
|
||||
let beltVisible = true;
|
||||
|
||||
const beltAnchor = document.querySelector<HTMLElement>('[data-belt-anchor]');
|
||||
if (beltAnchor) {
|
||||
beltVisible = false;
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
beltVisible = entries[0]?.isIntersecting ?? true;
|
||||
},
|
||||
{ rootMargin: '20% 0px' },
|
||||
).observe(beltAnchor);
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
const delta = window.scrollY - lastScrollY;
|
||||
lastScrollY = window.scrollY;
|
||||
if (!beltVisible) return;
|
||||
if (delta > 0) downEnergy = Math.min(1.6, downEnergy + delta * 0.004);
|
||||
else if (delta < 0) upEnergy = Math.min(1.6, upEnergy - delta * 0.004);
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
|
||||
/* ── Simulation ────────────────────────────────────────────────────────── */
|
||||
const matrix = new THREE.Matrix4();
|
||||
const quaternion = new THREE.Quaternion();
|
||||
const scaleVec = new THREE.Vector3();
|
||||
const tmp = new THREE.Vector3();
|
||||
// Manual timing (THREE.Clock is deprecated).
|
||||
let lastTime = performance.now();
|
||||
let elapsed = 0;
|
||||
let raf = 0;
|
||||
let running = true;
|
||||
|
||||
function step() {
|
||||
raf = requestAnimationFrame(step);
|
||||
if (!running) return;
|
||||
const now = performance.now();
|
||||
const dt = Math.min((now - lastTime) / 1000, 0.05);
|
||||
lastTime = now;
|
||||
elapsed += dt;
|
||||
const t = elapsed;
|
||||
|
||||
stars.rotation.y = t * 0.004;
|
||||
downEnergy = Math.max(0, downEnergy - dt * 0.55);
|
||||
upEnergy = Math.max(0, upEnergy - dt * 0.4);
|
||||
|
||||
for (const a of asteroids) {
|
||||
// Scroll down: outward drift + slight chaos -> collisions happen.
|
||||
if (downEnergy > 0.01) {
|
||||
tmp.copy(a.position).sub(new THREE.Vector3(0, 0, -8)).normalize();
|
||||
a.velocity.addScaledVector(tmp, downEnergy * dt * 2.2);
|
||||
a.velocity.x += (Math.random() - 0.5) * downEnergy * dt * 3;
|
||||
a.velocity.y += (Math.random() - 0.5) * downEnergy * dt * 3;
|
||||
}
|
||||
|
||||
// Scroll up: vortex - tangential pull + gentle fall toward home orbit.
|
||||
if (upEnergy > 0.01) {
|
||||
tmp.set(-(a.position.y - a.home.y) - a.position.x * 0.3, a.position.x - a.home.x, 0);
|
||||
a.velocity.addScaledVector(tmp.normalize(), upEnergy * dt * 3.4);
|
||||
tmp.copy(a.home).sub(a.position);
|
||||
a.velocity.addScaledVector(tmp, upEnergy * dt * 0.9);
|
||||
}
|
||||
|
||||
// Always: soft spring back to formation + drag.
|
||||
tmp.copy(a.home).sub(a.position);
|
||||
a.velocity.addScaledVector(tmp, dt * 0.25);
|
||||
a.velocity.multiplyScalar(1 - dt * 0.8);
|
||||
a.position.addScaledVector(a.velocity, dt);
|
||||
|
||||
a.rotation.x += a.spin.x * dt;
|
||||
a.rotation.y += a.spin.y * dt;
|
||||
}
|
||||
|
||||
// Elastic collisions (spatially naive is fine at N=90).
|
||||
if (downEnergy > 0.05) {
|
||||
for (let i = 0; i < asteroids.length; i++) {
|
||||
const a = asteroids[i]!;
|
||||
for (let j = i + 1; j < asteroids.length; j++) {
|
||||
const b = asteroids[j]!;
|
||||
tmp.copy(b.position).sub(a.position);
|
||||
const dist = tmp.length();
|
||||
const minDist = a.radius + b.radius;
|
||||
if (dist > 0.0001 && dist < minDist) {
|
||||
tmp.normalize();
|
||||
const relative = tmp.dot(b.velocity.clone().sub(a.velocity));
|
||||
if (relative < 0) {
|
||||
const impulse = -relative;
|
||||
a.velocity.addScaledVector(tmp, -impulse * 0.5);
|
||||
b.velocity.addScaledVector(tmp, impulse * 0.5);
|
||||
const overlap = minDist - dist;
|
||||
a.position.addScaledVector(tmp, -overlap / 2);
|
||||
b.position.addScaledVector(tmp, overlap / 2);
|
||||
if (impulse > 1.2) {
|
||||
burst(a.position.clone().addScaledVector(tmp, a.radius), Math.min(impulse / 4, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < asteroids.length; i++) {
|
||||
const a = asteroids[i]!;
|
||||
quaternion.setFromEuler(a.rotation);
|
||||
scaleVec.setScalar(a.radius);
|
||||
matrix.compose(a.position, quaternion, scaleVec);
|
||||
belt.setMatrixAt(i, matrix);
|
||||
}
|
||||
belt.instanceMatrix.needsUpdate = true;
|
||||
|
||||
for (let i = 0; i < SPARK_POOL; i++) {
|
||||
if (sparkLife[i]! <= 0) continue;
|
||||
sparkLife[i]! -= dt;
|
||||
sparkVel[i]!.multiplyScalar(1 - dt * 2);
|
||||
sparkPositions[i * 3] += sparkVel[i]!.x * dt;
|
||||
sparkPositions[i * 3 + 1] += sparkVel[i]!.y * dt;
|
||||
sparkPositions[i * 3 + 2] += sparkVel[i]!.z * dt;
|
||||
if (sparkLife[i]! <= 0) sparkPositions[i * 3 + 1] = -999;
|
||||
}
|
||||
sparkGeometry.attributes.position!.needsUpdate = true;
|
||||
sparkMaterial.opacity = 0.5 + downEnergy * 0.3;
|
||||
|
||||
camera.position.x = Math.sin(t * 0.05) * 1.2;
|
||||
camera.position.y = Math.cos(t * 0.04) * 0.8;
|
||||
camera.lookAt(0, 0, -8);
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight, false);
|
||||
}
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
function onVisibility() {
|
||||
running = document.visibilityState === 'visible';
|
||||
if (running) lastTime = performance.now(); // swallow the pause
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
step();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('resize', onResize);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
renderer.dispose();
|
||||
rockGeometry.dispose();
|
||||
starGeometry.dispose();
|
||||
sparkGeometry.dispose();
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Irregular rock: a dodecahedron with jittered vertices. */
|
||||
function makeRockGeometry(): THREE.BufferGeometry {
|
||||
const geometry = new THREE.DodecahedronGeometry(1, 0);
|
||||
const pos = geometry.attributes.position!;
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
const jitter = 0.78 + Math.random() * 0.44;
|
||||
pos.setXYZ(i, pos.getX(i) * jitter, pos.getY(i) * jitter, pos.getZ(i) * jitter);
|
||||
}
|
||||
geometry.computeVertexNormals();
|
||||
return geometry;
|
||||
}
|
||||
|
||||
/** Radial-gradient sprite texture used for the nebula glow. */
|
||||
function makeGlowTexture(): THREE.Texture {
|
||||
const size = 128;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = canvas.height = size;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
const gradient = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
||||
gradient.addColorStop(0, 'rgba(255,255,255,1)');
|
||||
gradient.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
return texture;
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* The home "voyage" engine.
|
||||
*
|
||||
* One fixed WebGL canvas + fixed HTML overlays. The page's scroll height is a
|
||||
* timeline: scrolling never moves content - it advances a camera along a 3D
|
||||
* path (nebulae, asteroid belt, one beacon per project, a final portal) and
|
||||
* cross-fades the matching overlay. Scroll velocity feeds the belt physics:
|
||||
* scrolling down shakes asteroids into collisions (sparks), scrolling up pulls
|
||||
* them into a gravitational vortex back to formation.
|
||||
*
|
||||
* Progressive enhancement: without WebGL or with prefers-reduced-motion the
|
||||
* `cinema` class is never added and the sections render as a normal page.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import type Lenis from 'lenis';
|
||||
|
||||
interface Segment {
|
||||
id: string;
|
||||
el: HTMLElement;
|
||||
weight: number;
|
||||
side: 'left' | 'right' | 'center';
|
||||
isProject: boolean;
|
||||
/** Global progress window [start, end] (0..1). */
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
export interface VoyageHandle {
|
||||
seek(id: string, immediate?: boolean): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const DEPTH = 44; // world units per weight unit of scroll
|
||||
const BG = 0x040b0b;
|
||||
const CYAN = 0x2efafa;
|
||||
const TEAL = 0x34c2c9;
|
||||
const DEEP = 0x155e75;
|
||||
|
||||
function glowTexture(hex: string): THREE.Texture {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = 256;
|
||||
const ctx = c.getContext('2d')!;
|
||||
const g = ctx.createRadialGradient(128, 128, 0, 128, 128, 128);
|
||||
g.addColorStop(0, `${hex}ff`);
|
||||
g.addColorStop(0.35, `${hex}66`);
|
||||
g.addColorStop(1, `${hex}00`);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
return tex;
|
||||
}
|
||||
|
||||
const smoothstep = (a: number, b: number, x: number) => {
|
||||
const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
/**
|
||||
* Dwell curve: rises to 0.5 over the first 38% of a segment, HOLDS through
|
||||
* the middle, finishes over the last 38%. Applied to the camera's progress
|
||||
* inside each segment, so every step of the voyage locks in place while the
|
||||
* matching card is on screen - small scroll moves barely shift the view.
|
||||
*/
|
||||
const dwell = (t: number) => 0.5 * smoothstep(0, 0.38, t) + 0.5 * smoothstep(0.62, 1, t);
|
||||
|
||||
export function mountVoyage(opts: {
|
||||
canvas: HTMLCanvasElement;
|
||||
container: HTMLElement;
|
||||
lenis: Lenis;
|
||||
}): VoyageHandle | null {
|
||||
const { canvas, container, lenis } = opts;
|
||||
|
||||
// --- Capability gate -----------------------------------------------------
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
try {
|
||||
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMobile = window.matchMedia('(max-width: 840px)').matches;
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, isMobile ? 1.75 : 2));
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setClearColor(BG, 1);
|
||||
|
||||
// --- Timeline from the DOM ----------------------------------------------
|
||||
const els = Array.from(container.querySelectorAll<HTMLElement>('[data-seg]'));
|
||||
if (!els.length) return null;
|
||||
|
||||
const segments: Segment[] = els.map((el) => ({
|
||||
id: el.dataset.seg!,
|
||||
el,
|
||||
weight: Number(el.dataset.weight ?? 1),
|
||||
side: (el.dataset.side as Segment['side']) ?? 'center',
|
||||
isProject: 'project' in el.dataset,
|
||||
start: 0,
|
||||
end: 0,
|
||||
}));
|
||||
const totalWeight = segments.reduce((s, x) => s + x.weight, 0);
|
||||
let acc = 0;
|
||||
for (const s of segments) {
|
||||
s.start = acc / totalWeight;
|
||||
acc += s.weight;
|
||||
s.end = acc / totalWeight;
|
||||
}
|
||||
|
||||
// Cinema mode on: sections become fixed overlays, the container becomes the timeline.
|
||||
document.documentElement.classList.add('cinema');
|
||||
container.style.height = `${totalWeight * 100 + 60}vh`;
|
||||
|
||||
// --- Scene ---------------------------------------------------------------
|
||||
const scene = new THREE.Scene();
|
||||
scene.fog = new THREE.FogExp2(BG, 0.0072);
|
||||
const camera = new THREE.PerspectiveCamera(58, window.innerWidth / window.innerHeight, 0.1, 400);
|
||||
|
||||
// Flight path: one control point per segment boundary, gently weaving.
|
||||
const points: THREE.Vector3[] = [];
|
||||
let cum = 0;
|
||||
for (let i = 0; i <= segments.length; i++) {
|
||||
points.push(new THREE.Vector3(7 * Math.sin(i * 1.9), 2.4 * Math.sin(i * 1.3 + 1), -cum * DEPTH));
|
||||
cum += segments[i]?.weight ?? 1;
|
||||
}
|
||||
const path = new THREE.CatmullRomCurve3(points, false, 'catmullrom', 0.35);
|
||||
const END_Z = points[points.length - 1]!.z;
|
||||
|
||||
// Starfield: a long tube of stars around the whole path.
|
||||
{
|
||||
const count = isMobile ? 900 : 2000;
|
||||
const pos = new Float32Array(count * 3);
|
||||
const col = new Float32Array(count * 3);
|
||||
const cCyan = new THREE.Color(CYAN);
|
||||
const cWhite = new THREE.Color(0xdfffff);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const r = 16 + Math.random() * 85;
|
||||
pos[i * 3] = Math.cos(a) * r;
|
||||
pos[i * 3 + 1] = Math.sin(a) * r * 0.7;
|
||||
pos[i * 3 + 2] = 30 + Math.random() * (END_Z - 90);
|
||||
const c = Math.random() < 0.25 ? cCyan : cWhite;
|
||||
col[i * 3] = c.r;
|
||||
col[i * 3 + 1] = c.g;
|
||||
col[i * 3 + 2] = c.b;
|
||||
}
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
|
||||
geo.setAttribute('color', new THREE.BufferAttribute(col, 3));
|
||||
scene.add(
|
||||
new THREE.Points(
|
||||
geo,
|
||||
new THREE.PointsMaterial({
|
||||
size: 0.55,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Nebulae: big soft sprites drifting near the path.
|
||||
const nebulae: THREE.Sprite[] = [];
|
||||
{
|
||||
const colors = ['#2EFAFA', '#34C2C9', '#155E75', '#2EFAFA', '#0E7490'];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const mat = new THREE.SpriteMaterial({
|
||||
map: glowTexture(colors[i]!),
|
||||
transparent: true,
|
||||
opacity: 0.14 + (i % 2) * 0.08,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
});
|
||||
const sp = new THREE.Sprite(mat);
|
||||
const u = (i + 0.5) / 5;
|
||||
const p = path.getPointAt(u);
|
||||
sp.position.set(p.x + (i % 2 ? -1 : 1) * (40 + i * 8), p.y + (i % 2 ? 14 : -10), p.z - 30);
|
||||
const s = 150 + i * 30;
|
||||
sp.scale.set(s, s, 1);
|
||||
nebulae.push(sp);
|
||||
scene.add(sp);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Asteroid belt across the project segments ---------------------------
|
||||
const projectSegs = segments.filter((s) => s.isProject);
|
||||
const beltZ0 = -((projectSegs[0]?.start ?? 0.3) * totalWeight) * DEPTH + 10;
|
||||
const beltZ1 = -((projectSegs[projectSegs.length - 1]?.end ?? 0.7) * totalWeight) * DEPTH - 10;
|
||||
|
||||
const AST = isMobile ? 48 : 110;
|
||||
const belt = new THREE.InstancedMesh(
|
||||
new THREE.DodecahedronGeometry(1, 0),
|
||||
new THREE.MeshBasicMaterial({ color: 0x0d3b3f, wireframe: false }),
|
||||
AST,
|
||||
);
|
||||
const beltWire = new THREE.InstancedMesh(
|
||||
new THREE.DodecahedronGeometry(1.001, 0),
|
||||
new THREE.MeshBasicMaterial({ color: TEAL, wireframe: true, transparent: true, opacity: 0.45 }),
|
||||
AST,
|
||||
);
|
||||
interface Rock {
|
||||
base: THREE.Vector3;
|
||||
pos: THREE.Vector3;
|
||||
vel: THREE.Vector3;
|
||||
r: number;
|
||||
spin: THREE.Vector3;
|
||||
rot: THREE.Euler;
|
||||
}
|
||||
const rocks: Rock[] = [];
|
||||
for (let i = 0; i < AST; i++) {
|
||||
const a = Math.random() * Math.PI * 2;
|
||||
const rad = 10 + Math.random() * 17;
|
||||
const base = new THREE.Vector3(
|
||||
Math.cos(a) * rad,
|
||||
Math.sin(a) * rad * 0.55,
|
||||
beltZ0 + Math.random() * (beltZ1 - beltZ0),
|
||||
);
|
||||
rocks.push({
|
||||
base,
|
||||
pos: base.clone(),
|
||||
vel: new THREE.Vector3((Math.random() - 0.5), (Math.random() - 0.5), (Math.random() - 0.5)).multiplyScalar(2.2),
|
||||
r: 0.45 + Math.random() * 1.3,
|
||||
spin: new THREE.Vector3(Math.random(), Math.random(), Math.random()).multiplyScalar(0.8),
|
||||
rot: new THREE.Euler(Math.random() * 3, Math.random() * 3, Math.random() * 3),
|
||||
});
|
||||
}
|
||||
scene.add(belt, beltWire);
|
||||
|
||||
// Spark pool for collisions.
|
||||
const SPARKS = 300;
|
||||
const sparkPos = new Float32Array(SPARKS * 3);
|
||||
const sparkVel: THREE.Vector3[] = [];
|
||||
const sparkLife = new Float32Array(SPARKS);
|
||||
for (let i = 0; i < SPARKS; i++) sparkVel.push(new THREE.Vector3());
|
||||
const sparkGeo = new THREE.BufferGeometry();
|
||||
sparkGeo.setAttribute('position', new THREE.BufferAttribute(sparkPos, 3));
|
||||
const sparks = new THREE.Points(
|
||||
sparkGeo,
|
||||
new THREE.PointsMaterial({
|
||||
color: CYAN,
|
||||
size: 0.5,
|
||||
transparent: true,
|
||||
opacity: 0.95,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
scene.add(sparks);
|
||||
let sparkCursor = 0;
|
||||
const burst = (at: THREE.Vector3, n: number) => {
|
||||
for (let k = 0; k < n; k++) {
|
||||
const i = sparkCursor++ % SPARKS;
|
||||
sparkPos[i * 3] = at.x;
|
||||
sparkPos[i * 3 + 1] = at.y;
|
||||
sparkPos[i * 3 + 2] = at.z;
|
||||
sparkVel[i]!.set(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).multiplyScalar(9);
|
||||
sparkLife[i] = 1;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Beacons (one per project) + final portal -----------------------------
|
||||
interface Beacon {
|
||||
group: THREE.Group;
|
||||
ring: THREE.Mesh;
|
||||
glow: THREE.Sprite;
|
||||
seg: Segment;
|
||||
}
|
||||
const beacons: Beacon[] = [];
|
||||
const beaconGlowTex = glowTexture('#2EFAFA');
|
||||
for (const seg of projectSegs) {
|
||||
const mid = (seg.start + seg.end) / 2;
|
||||
const p = path.getPointAt(mid);
|
||||
const dir = seg.side === 'left' ? 1 : -1; // beacon opposite to the text card
|
||||
const group = new THREE.Group();
|
||||
group.position.set(p.x + dir * 7.5, p.y + 0.5, p.z - 6);
|
||||
|
||||
const core = new THREE.Mesh(
|
||||
new THREE.IcosahedronGeometry(1.1, 1),
|
||||
new THREE.MeshBasicMaterial({ color: CYAN, wireframe: true, transparent: true, opacity: 0.9 }),
|
||||
);
|
||||
const ring = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(2.2, 0.045, 10, 72),
|
||||
new THREE.MeshBasicMaterial({ color: TEAL, transparent: true, opacity: 0.7 }),
|
||||
);
|
||||
ring.rotation.x = Math.PI / 2.4;
|
||||
const glow = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: beaconGlowTex,
|
||||
transparent: true,
|
||||
opacity: 0.5,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
glow.scale.set(9, 9, 1);
|
||||
group.add(core, ring, glow);
|
||||
scene.add(group);
|
||||
beacons.push({ group, ring, glow, seg });
|
||||
}
|
||||
|
||||
const portal = new THREE.Group();
|
||||
{
|
||||
const g1 = new THREE.Sprite(
|
||||
new THREE.SpriteMaterial({
|
||||
map: glowTexture('#2EFAFA'),
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
}),
|
||||
);
|
||||
g1.scale.set(46, 46, 1);
|
||||
const r1 = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(9, 0.12, 12, 96),
|
||||
new THREE.MeshBasicMaterial({ color: CYAN, transparent: true, opacity: 0.8 }),
|
||||
);
|
||||
const r2 = new THREE.Mesh(
|
||||
new THREE.TorusGeometry(12.5, 0.06, 12, 96),
|
||||
new THREE.MeshBasicMaterial({ color: DEEP, transparent: true, opacity: 0.7 }),
|
||||
);
|
||||
portal.add(g1, r1, r2);
|
||||
portal.position.set(0, 0, END_Z - 46);
|
||||
scene.add(portal);
|
||||
}
|
||||
|
||||
// --- Overlay + progress UI ------------------------------------------------
|
||||
const progressBar = document.querySelector<HTMLElement>('[data-voyage-progress]');
|
||||
const dots = Array.from(document.querySelectorAll<HTMLElement>('[data-voyage-dot]'));
|
||||
|
||||
const applyOverlay = (p: number) => {
|
||||
for (const s of segments) {
|
||||
const span = s.end - s.start;
|
||||
const local = (p - s.start) / span;
|
||||
const first = s === segments[0];
|
||||
const last = s === segments[segments.length - 1];
|
||||
const inV = first ? 1 : smoothstep(0.04, 0.22, local);
|
||||
const outV = last ? 1 : 1 - smoothstep(0.8, 0.97, local);
|
||||
const vis = Math.max(0, Math.min(inV, outV));
|
||||
const el = s.el;
|
||||
if (local < -0.25 || local > 1.25) {
|
||||
if (el.style.visibility !== 'hidden') el.style.visibility = 'hidden';
|
||||
continue;
|
||||
}
|
||||
el.style.visibility = 'visible';
|
||||
el.style.opacity = String(vis);
|
||||
const y = (1 - inV) * 52 - (1 - outV) * 52;
|
||||
el.style.transform = `translateY(${y.toFixed(1)}px) scale(${(0.985 + 0.015 * vis).toFixed(3)})`;
|
||||
el.classList.toggle('is-active', vis > 0.45);
|
||||
}
|
||||
if (progressBar) progressBar.style.transform = `scaleX(${p.toFixed(4)})`;
|
||||
const active = segments.findIndex((s) => p >= s.start && p < s.end);
|
||||
dots.forEach((d, i) => d.classList.toggle('is-active', i === Math.max(0, active)));
|
||||
};
|
||||
|
||||
// --- Frame loop ------------------------------------------------------------
|
||||
let progress = 0;
|
||||
let downEnergy = 0;
|
||||
let upEnergy = 0;
|
||||
let idleTime = 0;
|
||||
let snapped = true; // don't snap on load
|
||||
let elapsed = 0;
|
||||
let last = performance.now();
|
||||
let running = true;
|
||||
let frame = 0;
|
||||
// FPS watchdog: warm up 1.5s, then sample 2.5s. Below ~24fps the machine
|
||||
// can't hold the scene (WebGL existing doesn't mean a GPU worth using) -
|
||||
// tear down and give them the static page instead of a slideshow.
|
||||
let fpsFrames = 0;
|
||||
let fpsTime = 0;
|
||||
let fpsChecked = false;
|
||||
|
||||
const camTarget = new THREE.Vector3();
|
||||
const tmp = new THREE.Vector3();
|
||||
const mat4 = new THREE.Matrix4();
|
||||
const quat = new THREE.Quaternion();
|
||||
const scl = new THREE.Vector3();
|
||||
|
||||
const maxScroll = () => Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
|
||||
|
||||
const tick = () => {
|
||||
if (!running) {
|
||||
frame = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
last = now;
|
||||
elapsed += dt;
|
||||
|
||||
if (!fpsChecked && elapsed > 1.5) {
|
||||
fpsFrames++;
|
||||
fpsTime += dt;
|
||||
if (fpsTime > 2.5) {
|
||||
fpsChecked = true;
|
||||
if (fpsFrames / fpsTime < 24) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll → timeline progress + belt energies (Lenis keeps this smooth).
|
||||
const raw = lenis.scroll / maxScroll();
|
||||
progress = Math.min(1, Math.max(0, raw));
|
||||
const v = lenis.velocity; // px per frame, signed
|
||||
downEnergy = lerp(downEnergy, Math.min(1, Math.max(0, v / 55)), 0.08);
|
||||
upEnergy = lerp(upEnergy, Math.min(1, Math.max(0, -v / 55)), 0.08);
|
||||
|
||||
// Idle snap: once the user pauses inside a segment, settle on its center
|
||||
// so the current step is framed cleanly (a soft scroll lock, not a jail -
|
||||
// any new scroll input takes over immediately).
|
||||
if (Math.abs(v) > 1.5) {
|
||||
idleTime = 0;
|
||||
snapped = false;
|
||||
} else {
|
||||
idleTime += dt;
|
||||
}
|
||||
if (!snapped && idleTime > 0.35 && progress > 0.005 && progress < 0.995) {
|
||||
const seg = segments.find((s) => progress >= s.start && progress < s.end);
|
||||
if (seg) {
|
||||
const center = seg.start + (seg.end - seg.start) * 0.5;
|
||||
const off = Math.abs(progress - center);
|
||||
if (off > 0.003 && off < (seg.end - seg.start) * 0.5) {
|
||||
snapped = true;
|
||||
lenis.scrollTo(center * maxScroll(), { duration: 0.9 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Camera along the path - with a per-segment dwell so each step locks.
|
||||
let u = progress;
|
||||
for (const s of segments) {
|
||||
if (progress >= s.start && progress <= s.end) {
|
||||
const span = s.end - s.start;
|
||||
u = s.start + span * dwell((progress - s.start) / span);
|
||||
break;
|
||||
}
|
||||
}
|
||||
path.getPointAt(u, camera.position);
|
||||
path.getPointAt(Math.min(1, u + 0.045), camTarget);
|
||||
// Ease the gaze toward the active beacon.
|
||||
for (const b of beacons) {
|
||||
const mid = (b.seg.start + b.seg.end) / 2;
|
||||
const w = 1 - Math.min(1, Math.abs(u - mid) / (b.seg.end - b.seg.start));
|
||||
if (w > 0) camTarget.lerp(b.group.position, w * 0.35);
|
||||
}
|
||||
camera.lookAt(camTarget);
|
||||
camera.position.y += Math.sin(elapsed * 0.7) * 0.12; // idle breathing
|
||||
|
||||
// Nebulae drift.
|
||||
for (let i = 0; i < nebulae.length; i++) {
|
||||
nebulae[i]!.material.rotation = elapsed * 0.02 * (i % 2 ? 1 : -1);
|
||||
}
|
||||
|
||||
// Belt physics.
|
||||
const agitated = downEnergy > 0.04;
|
||||
for (let i = 0; i < AST; i++) {
|
||||
const r = rocks[i]!;
|
||||
if (agitated) {
|
||||
r.pos.addScaledVector(r.vel, dt * (0.4 + downEnergy * 2.6));
|
||||
}
|
||||
if (upEnergy > 0.04) {
|
||||
// Vortex: tangential pull around the path axis + spring home.
|
||||
tmp.set(-r.pos.y, r.pos.x, 0).normalize();
|
||||
r.pos.addScaledVector(tmp, dt * upEnergy * 14);
|
||||
r.pos.lerp(r.base, dt * upEnergy * 1.6);
|
||||
} else if (!agitated) {
|
||||
r.pos.lerp(r.base, dt * 0.5);
|
||||
}
|
||||
r.rot.x += r.spin.x * dt * (1 + downEnergy * 3);
|
||||
r.rot.y += r.spin.y * dt;
|
||||
quat.setFromEuler(r.rot);
|
||||
scl.setScalar(r.r);
|
||||
mat4.compose(r.pos, quat, scl);
|
||||
belt.setMatrixAt(i, mat4);
|
||||
beltWire.setMatrixAt(i, mat4);
|
||||
}
|
||||
// Collisions only while agitated, only near the camera (cheap N² slice).
|
||||
if (agitated) {
|
||||
for (let i = 0; i < AST; i++) {
|
||||
const a = rocks[i]!;
|
||||
if (Math.abs(a.pos.z - camera.position.z) > 60) continue;
|
||||
for (let j = i + 1; j < AST; j++) {
|
||||
const b = rocks[j]!;
|
||||
const dz = a.pos.z - b.pos.z;
|
||||
if (dz > 6 || dz < -6) continue;
|
||||
const d2 = a.pos.distanceToSquared(b.pos);
|
||||
const rr = a.r + b.r;
|
||||
if (d2 < rr * rr) {
|
||||
tmp.subVectors(a.pos, b.pos).normalize();
|
||||
const va = a.vel.dot(tmp);
|
||||
const vb = b.vel.dot(tmp);
|
||||
a.vel.addScaledVector(tmp, vb - va);
|
||||
b.vel.addScaledVector(tmp, va - vb);
|
||||
a.pos.addScaledVector(tmp, rr - Math.sqrt(d2));
|
||||
burst(tmp.copy(a.pos).lerp(b.pos, 0.5), 7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
belt.instanceMatrix.needsUpdate = true;
|
||||
beltWire.instanceMatrix.needsUpdate = true;
|
||||
|
||||
// Sparks.
|
||||
for (let i = 0; i < SPARKS; i++) {
|
||||
if (sparkLife[i]! <= 0) continue;
|
||||
sparkLife[i]! -= dt * 1.4;
|
||||
sparkPos[i * 3] += sparkVel[i]!.x * dt;
|
||||
sparkPos[i * 3 + 1] += sparkVel[i]!.y * dt;
|
||||
sparkPos[i * 3 + 2] += sparkVel[i]!.z * dt;
|
||||
if (sparkLife[i]! <= 0) sparkPos[i * 3 + 1] = 9999;
|
||||
}
|
||||
sparkGeo.attributes.position!.needsUpdate = true;
|
||||
|
||||
// Beacons pulse; the active one lights up.
|
||||
for (const b of beacons) {
|
||||
const mid = (b.seg.start + b.seg.end) / 2;
|
||||
const w = Math.max(0, 1 - Math.abs(u - mid) / ((b.seg.end - b.seg.start) * 0.9));
|
||||
const pulse = 1 + Math.sin(elapsed * 2.2) * 0.05;
|
||||
b.group.scale.setScalar((0.7 + w * 0.5) * pulse);
|
||||
b.ring.rotation.z = elapsed * 0.6;
|
||||
(b.glow.material as THREE.SpriteMaterial).opacity = 0.25 + w * 0.55;
|
||||
b.group.rotation.y = elapsed * 0.35;
|
||||
}
|
||||
|
||||
// Portal breathes and brightens on approach.
|
||||
const arrive = smoothstep(0.82, 1, u);
|
||||
portal.rotation.z = elapsed * 0.25;
|
||||
portal.scale.setScalar(0.85 + arrive * 0.35 + Math.sin(elapsed * 1.4) * 0.02);
|
||||
|
||||
applyOverlay(progress);
|
||||
renderer.render(scene, camera);
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
// --- Wiring ----------------------------------------------------------------
|
||||
const onResize = () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
};
|
||||
const onVisibility = () => {
|
||||
running = !document.hidden;
|
||||
if (running) last = performance.now();
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
const seek = (id: string, immediate = false) => {
|
||||
const s = segments.find((x) => x.id === id);
|
||||
if (!s) return;
|
||||
const target = (s.start + (s.end - s.start) * 0.45) * maxScroll();
|
||||
lenis.scrollTo(target, immediate ? { immediate: true } : { duration: 1.8 });
|
||||
};
|
||||
|
||||
dots.forEach((d) => d.addEventListener('click', () => seek(d.dataset.voyageDot!)));
|
||||
|
||||
const dispose = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', onResize);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
renderer.dispose();
|
||||
document.documentElement.classList.remove('cinema');
|
||||
container.style.height = '';
|
||||
for (const s of segments) {
|
||||
s.el.removeAttribute('style');
|
||||
s.el.classList.remove('is-active');
|
||||
}
|
||||
};
|
||||
|
||||
applyOverlay(0);
|
||||
frame = requestAnimationFrame(tick);
|
||||
|
||||
return { seek, dispose };
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
---
|
||||
/**
|
||||
* Custom scene for `portfolio` (this very site): from wireframe to product.
|
||||
*
|
||||
* A pinned browser-window blueprint whose strokes draw themselves as you
|
||||
* scroll, then light up with the NDS accent - the site building itself.
|
||||
* Reduced motion / no JS: fully drawn and lit, no pin.
|
||||
*/
|
||||
import Eyebrow from '../Eyebrow.astro';
|
||||
import { getLocaleFromUrl, useTranslations } from '../../i18n';
|
||||
import type { Localized } from '../../data/projects';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const captions: Localized[] = [
|
||||
{ fr: 'Structure : Astro, routes FR/EN, données typées.', en: 'Structure: Astro, FR/EN routes, typed data.' },
|
||||
{ fr: 'Habillage : chaque pixel vient des tokens du Nova Design System.', en: 'Skin: every pixel comes from Nova Design System tokens.' },
|
||||
{ fr: 'Mise en orbite : build statique, CI Gitea, déployé sur le VPS.', en: 'Into orbit: static build, Gitea CI, deployed on the VPS.' },
|
||||
];
|
||||
|
||||
// Wireframe strokes in draw order (all use pathLength=1 for easy dash scrub).
|
||||
const strokes: { d: string; cls?: string }[] = [
|
||||
{ d: 'M 60 40 H 900 A 16 16 0 0 1 916 56 V 484 A 16 16 0 0 1 900 500 H 60 A 16 16 0 0 1 44 484 V 56 A 16 16 0 0 1 60 40 Z' }, // window
|
||||
{ d: 'M 44 88 H 916' }, // top bar divider
|
||||
{ d: 'M 76 64 a 8 8 0 1 0 0.01 0' },
|
||||
{ d: 'M 104 64 a 8 8 0 1 0 0.01 0' },
|
||||
{ d: 'M 132 64 a 8 8 0 1 0 0.01 0' },
|
||||
{ d: 'M 180 56 H 780 A 8 8 0 0 1 788 64 A 8 8 0 0 1 780 72 H 180 A 8 8 0 0 1 172 64 A 8 8 0 0 1 180 56 Z' }, // url bar
|
||||
{ d: 'M 92 150 H 420' }, // hero line 1
|
||||
{ d: 'M 92 186 H 560' }, // hero line 2 (big)
|
||||
{ d: 'M 92 222 H 340' }, // hero line 3
|
||||
{ d: 'M 92 262 H 208 A 10 10 0 0 1 218 272 V 286 A 10 10 0 0 1 208 296 H 92 A 10 10 0 0 1 82 286 V 272 A 10 10 0 0 1 92 262 Z', cls: 'accent' }, // CTA
|
||||
{ d: 'M 92 340 H 340 V 460 H 92 Z' }, // card 1
|
||||
{ d: 'M 366 340 H 614 V 460 H 366 Z' }, // card 2
|
||||
{ d: 'M 640 340 H 888 V 460 H 640 Z' }, // card 3
|
||||
{ d: 'M 640 140 a 70 70 0 1 0 0.01 0', cls: 'accent' }, // planet in hero
|
||||
{ d: 'M 610 210 q 100 -60 200 -12', cls: 'accent' }, // planet ring
|
||||
];
|
||||
---
|
||||
|
||||
<section
|
||||
class="bp"
|
||||
data-blueprint
|
||||
data-captions={JSON.stringify(captions.map((c) => c[locale]))}
|
||||
>
|
||||
<div class="stage">
|
||||
<header class="stage-head">
|
||||
<Eyebrow>{t('scene.blueprint.eyebrow')}</Eyebrow>
|
||||
<h2>{t('scene.blueprint.title')}</h2>
|
||||
</header>
|
||||
|
||||
<svg viewBox="0 0 960 540" class="draw" role="img" aria-label={t('scene.blueprint.title')}>
|
||||
<!-- Blueprint grid -->
|
||||
<g class="grid" aria-hidden="true">
|
||||
{Array.from({ length: 12 }, (_, i) => <line x1={44 + i * 80} y1="40" x2={44 + i * 80} y2="500" />)}
|
||||
{Array.from({ length: 7 }, (_, i) => <line x1="44" y1={40 + i * 78} x2="916" y2={40 + i * 78} />)}
|
||||
</g>
|
||||
|
||||
{strokes.map((s, i) => (
|
||||
<path d={s.d} pathLength="1" class:list={['stroke', s.cls]} data-draw={i} />
|
||||
))}
|
||||
|
||||
<!-- Lit state: soft fills that fade in at the end -->
|
||||
<g class="lit" data-lit aria-hidden="true">
|
||||
<rect x="82" y="262" width="136" height="34" rx="10" />
|
||||
<circle cx="640" cy="210" r="70" />
|
||||
<rect x="92" y="340" width="248" height="120" class="dim" />
|
||||
<rect x="366" y="340" width="248" height="120" class="dim" />
|
||||
<rect x="640" y="340" width="248" height="120" class="dim" />
|
||||
</g>
|
||||
|
||||
<text x="480" y="70" text-anchor="middle" class="url" data-url>louis-potevin.dev</text>
|
||||
</svg>
|
||||
|
||||
<p class="caption" data-caption aria-live="polite">{captions[0]![locale]}</p>
|
||||
<p class="hint" aria-hidden="true">{t('scene.hint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { scrub, window01 } from '../../lib/scrub';
|
||||
|
||||
function init() {
|
||||
const root = document.querySelector<HTMLElement>('[data-blueprint]');
|
||||
if (!root) return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
root.classList.add('static');
|
||||
return;
|
||||
}
|
||||
root.classList.add('is-ready');
|
||||
|
||||
const paths = Array.from(root.querySelectorAll<SVGPathElement>('[data-draw]'));
|
||||
const lit = root.querySelector<SVGGElement>('[data-lit]')!;
|
||||
const url = root.querySelector<SVGTextElement>('[data-url]')!;
|
||||
const captionEl = root.querySelector<HTMLElement>('[data-caption]')!;
|
||||
const captions: string[] = JSON.parse(root.dataset.captions ?? '[]');
|
||||
|
||||
scrub(root, (p) => {
|
||||
// Strokes draw over the first 68%, staggered.
|
||||
paths.forEach((el, i) => {
|
||||
const a = 0.03 + (i / paths.length) * 0.6;
|
||||
el.style.strokeDashoffset = String(1 - window01(p, a, a + 0.1));
|
||||
});
|
||||
// Then the interface lights up.
|
||||
const glow = window01(p, 0.7, 0.92);
|
||||
lit.style.opacity = String(glow * 0.9);
|
||||
url.style.opacity = String(window01(p, 0.32, 0.42));
|
||||
root.style.setProperty('--bp-glow', String(glow));
|
||||
|
||||
const idx = p < 0.34 ? 0 : p < 0.7 ? 1 : 2;
|
||||
if (captionEl.dataset.idx !== String(idx)) {
|
||||
captionEl.dataset.idx = String(idx);
|
||||
captionEl.style.opacity = '0';
|
||||
window.setTimeout(() => {
|
||||
captionEl.textContent = captions[idx]!;
|
||||
captionEl.style.opacity = '1';
|
||||
}, 180);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../styles/type' as type;
|
||||
|
||||
.bp {
|
||||
margin-block: clamp(40px, 8vh, 96px);
|
||||
|
||||
&.is-ready {
|
||||
height: 280vh;
|
||||
}
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-lg);
|
||||
justify-items: center;
|
||||
padding-block: var(--nds-spacing-xl);
|
||||
|
||||
.is-ready & {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100svh;
|
||||
align-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-head {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xs);
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
@include type.text-3xl;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
}
|
||||
|
||||
.draw {
|
||||
width: min(880px, 100%);
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 0 calc(var(--bp-glow, 0) * 14px) color-mix(in srgb, var(--nds-accent) 40%, transparent));
|
||||
}
|
||||
|
||||
.grid line {
|
||||
stroke: color-mix(in srgb, var(--nds-primary) 12%, transparent);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.stroke {
|
||||
fill: none;
|
||||
stroke: var(--nds-primary);
|
||||
stroke-width: 1.6;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
stroke-linecap: round;
|
||||
|
||||
&.accent {
|
||||
stroke: var(--nds-accent);
|
||||
}
|
||||
}
|
||||
|
||||
.lit {
|
||||
opacity: 0;
|
||||
|
||||
rect,
|
||||
circle {
|
||||
fill: color-mix(in srgb, var(--nds-accent) 30%, transparent);
|
||||
}
|
||||
.dim {
|
||||
fill: color-mix(in srgb, var(--nds-primary) 14%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.url {
|
||||
@include type.text-label;
|
||||
fill: var(--nds-neutral);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 13px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.caption {
|
||||
@include type.text-base;
|
||||
min-height: 3em;
|
||||
max-width: 60ch;
|
||||
text-align: center;
|
||||
color: var(--nds-neutral);
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.hint {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-disabled);
|
||||
|
||||
.static & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Static fallback: fully drawn and lit. */
|
||||
.bp:not(.is-ready) {
|
||||
.stroke {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
.lit {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.url {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,329 @@
|
||||
---
|
||||
/**
|
||||
* Custom scene for `nova-infra`: the journey of a request.
|
||||
*
|
||||
* A pinned SVG network (Visitor → Cloudflare → Apache/VPS → services) whose
|
||||
* edges and nodes are drawn by scroll. Once drawn, a packet keeps traveling
|
||||
* the graph (time-based) so the diagram feels alive. Captions follow the
|
||||
* scroll. Reduced motion / no JS: the fully drawn diagram, no pin.
|
||||
*/
|
||||
import Eyebrow from '../Eyebrow.astro';
|
||||
import { getLocaleFromUrl, useTranslations } from '../../i18n';
|
||||
import type { Localized } from '../../data/projects';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const captions: Localized[] = [
|
||||
{
|
||||
fr: 'Une requête arrive : DNS et TLS passent par Cloudflare, qui filtre et met en cache.',
|
||||
en: 'A request comes in: DNS and TLS go through Cloudflare, which filters and caches.',
|
||||
},
|
||||
{
|
||||
fr: "Apache, en reverse proxy sur le VPS OVH, route chaque domaine vers le bon service.",
|
||||
en: 'Apache, reverse-proxying on the OVH VPS, routes each domain to the right service.',
|
||||
},
|
||||
{
|
||||
fr: 'Gitea, Vaultwarden, le runner CI et les sites tournent côte à côte - auto-hébergés, sauvegardés, monitorés.',
|
||||
en: 'Gitea, Vaultwarden, the CI runner and the sites run side by side - self-hosted, backed up, monitored.',
|
||||
},
|
||||
];
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
label: string;
|
||||
sub?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
}
|
||||
const nodes: Node[] = [
|
||||
{ id: 'visitor', label: locale === 'fr' ? 'Visiteur' : 'Visitor', x: 40, y: 236, w: 130 },
|
||||
{ id: 'cf', label: 'Cloudflare', sub: 'DNS · TLS · cache', x: 250, y: 236, w: 160 },
|
||||
{ id: 'apache', label: 'Apache', sub: 'reverse proxy', x: 490, y: 236, w: 160 },
|
||||
{ id: 'gitea', label: 'Gitea', sub: 'git.novaprojects.dev', x: 750, y: 90, w: 180 },
|
||||
{ id: 'vault', label: 'Vaultwarden', sub: locale === 'fr' ? 'mots de passe' : 'passwords', x: 750, y: 236, w: 180 },
|
||||
{ id: 'sites', label: 'Sites', sub: 'louis-potevin.dev', x: 750, y: 382, w: 180 },
|
||||
];
|
||||
|
||||
// Edges as SVG path data (right side of source → left side of target).
|
||||
const edges: { id: string; d: string }[] = [
|
||||
{ id: 'e-visitor-cf', d: 'M 170 264 C 210 264 210 264 250 264' },
|
||||
{ id: 'e-cf-apache', d: 'M 410 264 C 450 264 450 264 490 264' },
|
||||
{ id: 'e-apache-gitea', d: 'M 650 264 C 710 264 690 118 750 118' },
|
||||
{ id: 'e-apache-vault', d: 'M 650 264 C 690 264 710 264 750 264' },
|
||||
{ id: 'e-apache-sites', d: 'M 650 264 C 710 264 690 410 750 410' },
|
||||
];
|
||||
---
|
||||
|
||||
<section class="infra" data-infra data-captions={JSON.stringify(captions.map((c) => c[locale]))}>
|
||||
<div class="stage">
|
||||
<header class="stage-head">
|
||||
<Eyebrow>{t('scene.infra.eyebrow')}</Eyebrow>
|
||||
<h2>{t('scene.infra.title')}</h2>
|
||||
</header>
|
||||
|
||||
<svg viewBox="0 0 960 500" class="net" role="img" aria-label={t('scene.infra.title')}>
|
||||
<!-- VPS enclosure -->
|
||||
<g data-node="vps">
|
||||
<rect x="460" y="40" width="486" height="430" rx="16" class="vps-box" />
|
||||
<text x="482" y="72" class="vps-label">VPS OVH · Debian</text>
|
||||
</g>
|
||||
|
||||
{edges.map((e) => <path id={e.id} d={e.d} pathLength="1" class="edge" data-edge />)}
|
||||
|
||||
{nodes.map((n) => (
|
||||
<g data-node={n.id} class="node">
|
||||
<rect x={n.x} y={n.y} width={n.w} height="56" rx="10" />
|
||||
<text x={n.x + 16} y={n.y + (n.sub ? 24 : 33)} class="node-label">{n.label}</text>
|
||||
{n.sub && <text x={n.x + 16} y={n.y + 42} class="node-sub">{n.sub}</text>}
|
||||
</g>
|
||||
))}
|
||||
|
||||
<circle class="packet" data-packet r="5" cx="-20" cy="-20" />
|
||||
<circle class="packet packet--trail" data-packet-trail r="9" cx="-20" cy="-20" />
|
||||
</svg>
|
||||
|
||||
<p class="caption" data-caption aria-live="polite">{captions[0]![locale]}</p>
|
||||
<p class="hint" aria-hidden="true">{t('scene.hint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { scrub, window01 } from '../../lib/scrub';
|
||||
|
||||
function init() {
|
||||
const root = document.querySelector<HTMLElement>('[data-infra]');
|
||||
if (!root) return;
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (reduced) {
|
||||
root.classList.add('static');
|
||||
return;
|
||||
}
|
||||
root.classList.add('is-ready');
|
||||
|
||||
const edges = Array.from(root.querySelectorAll<SVGPathElement>('[data-edge]'));
|
||||
const nodes = Array.from(root.querySelectorAll<SVGGElement>('[data-node]'));
|
||||
const packet = root.querySelector<SVGCircleElement>('[data-packet]')!;
|
||||
const trail = root.querySelector<SVGCircleElement>('[data-packet-trail]')!;
|
||||
const captionEl = root.querySelector<HTMLElement>('[data-caption]')!;
|
||||
const captions: string[] = JSON.parse(root.dataset.captions ?? '[]');
|
||||
|
||||
// Draw order: vps box → visitor → e1 → cf → e2 → apache → branches.
|
||||
const drawOrder: (SVGElement | undefined)[] = [
|
||||
nodes.find((n) => n.dataset.node === 'vps'),
|
||||
nodes.find((n) => n.dataset.node === 'visitor'),
|
||||
edges[0],
|
||||
nodes.find((n) => n.dataset.node === 'cf'),
|
||||
edges[1],
|
||||
nodes.find((n) => n.dataset.node === 'apache'),
|
||||
edges[2],
|
||||
nodes.find((n) => n.dataset.node === 'gitea'),
|
||||
edges[3],
|
||||
nodes.find((n) => n.dataset.node === 'vault'),
|
||||
edges[4],
|
||||
nodes.find((n) => n.dataset.node === 'sites'),
|
||||
];
|
||||
|
||||
let progress = 0;
|
||||
scrub(root, (p) => {
|
||||
progress = p;
|
||||
// Sequential reveal across the first 70% of the scene.
|
||||
drawOrder.forEach((el, i) => {
|
||||
if (!el) return;
|
||||
const a = 0.05 + (i / drawOrder.length) * 0.62;
|
||||
const v = window01(p, a, a + 0.08);
|
||||
if (el.hasAttribute('data-edge')) {
|
||||
(el as SVGPathElement).style.strokeDashoffset = String(1 - v);
|
||||
} else {
|
||||
el.style.opacity = String(v);
|
||||
el.style.transform = `translateY(${(1 - v) * 14}px)`;
|
||||
}
|
||||
});
|
||||
// Captions in thirds.
|
||||
const idx = p < 0.34 ? 0 : p < 0.68 ? 1 : 2;
|
||||
if (captionEl.dataset.idx !== String(idx)) {
|
||||
captionEl.dataset.idx = String(idx);
|
||||
captionEl.style.opacity = '0';
|
||||
window.setTimeout(() => {
|
||||
captionEl.textContent = captions[idx]!;
|
||||
captionEl.style.opacity = '1';
|
||||
}, 180);
|
||||
}
|
||||
});
|
||||
|
||||
// Live packet: visitor → cf → apache → (cycling branch), time-based.
|
||||
const legs = [edges[0]!, edges[1]!];
|
||||
const branches = [edges[2]!, edges[3]!, edges[4]!];
|
||||
const lens = [...legs, ...branches].map((e) => e.getTotalLength());
|
||||
let tAcc = 0;
|
||||
let lastT = performance.now();
|
||||
let branch = 0;
|
||||
const loop = (now: number) => {
|
||||
const dt = Math.min((now - lastT) / 1000, 0.05);
|
||||
lastT = now;
|
||||
if (progress > 0.2) {
|
||||
tAcc += dt * 0.55;
|
||||
if (tAcc >= 1) {
|
||||
tAcc = 0;
|
||||
branch = (branch + 1) % branches.length;
|
||||
}
|
||||
// Route = leg0 (0-0.33) → leg1 (0.33-0.66) → branch (0.66-1).
|
||||
let el: SVGPathElement;
|
||||
let local: number;
|
||||
if (tAcc < 1 / 3) {
|
||||
el = legs[0]!;
|
||||
local = tAcc * 3;
|
||||
} else if (tAcc < 2 / 3) {
|
||||
el = legs[1]!;
|
||||
local = (tAcc - 1 / 3) * 3;
|
||||
} else {
|
||||
el = branches[branch]!;
|
||||
local = (tAcc - 2 / 3) * 3;
|
||||
}
|
||||
const len = lens[[...legs, ...branches].indexOf(el)]!;
|
||||
const pt = el.getPointAtLength(local * len);
|
||||
packet.setAttribute('cx', String(pt.x));
|
||||
packet.setAttribute('cy', String(pt.y));
|
||||
trail.setAttribute('cx', String(pt.x));
|
||||
trail.setAttribute('cy', String(pt.y));
|
||||
packet.style.opacity = '1';
|
||||
trail.style.opacity = '0.35';
|
||||
} else {
|
||||
packet.style.opacity = '0';
|
||||
trail.style.opacity = '0';
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../styles/type' as type;
|
||||
|
||||
.infra {
|
||||
/* Pinned scene: tall section, sticky stage. */
|
||||
margin-block: clamp(40px, 8vh, 96px);
|
||||
|
||||
&.is-ready {
|
||||
height: 300vh;
|
||||
}
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-lg);
|
||||
justify-items: center;
|
||||
padding-block: var(--nds-spacing-xl);
|
||||
|
||||
.is-ready & {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100svh;
|
||||
align-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-head {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xs);
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
@include type.text-3xl;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
}
|
||||
|
||||
.net {
|
||||
width: min(960px, 100%);
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.vps-box {
|
||||
fill: color-mix(in srgb, var(--nds-surface) 40%, transparent);
|
||||
stroke: var(--nds-border);
|
||||
stroke-dasharray: 6 6;
|
||||
}
|
||||
.vps-label {
|
||||
@include type.text-label;
|
||||
fill: var(--nds-disabled);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.edge {
|
||||
fill: none;
|
||||
stroke: var(--nds-primary);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.node {
|
||||
opacity: 0;
|
||||
|
||||
rect {
|
||||
fill: color-mix(in srgb, var(--nds-surface-2) 92%, transparent);
|
||||
stroke: var(--nds-primary);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.node-label {
|
||||
fill: var(--nds-text);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 15px;
|
||||
}
|
||||
.node-sub {
|
||||
fill: var(--nds-neutral);
|
||||
font-size: 11px;
|
||||
font-family: var(--nds-font-sans);
|
||||
}
|
||||
}
|
||||
g[data-node='vps'] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.packet {
|
||||
fill: var(--nds-accent);
|
||||
opacity: 0;
|
||||
filter: drop-shadow(0 0 6px var(--nds-accent));
|
||||
}
|
||||
.packet--trail {
|
||||
fill: var(--nds-accent);
|
||||
}
|
||||
|
||||
.caption {
|
||||
@include type.text-base;
|
||||
min-height: 3em;
|
||||
max-width: 60ch;
|
||||
text-align: center;
|
||||
color: var(--nds-neutral);
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.hint {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-disabled);
|
||||
|
||||
.static & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion / no JS: everything drawn, no pin. */
|
||||
.infra:not(.is-ready) {
|
||||
.edge {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
.node,
|
||||
g[data-node='vps'] {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
---
|
||||
/**
|
||||
* Custom scene for `file-organizer`: from chaos to order.
|
||||
*
|
||||
* File chips start scattered across the board (seeded pseudo-random, so the
|
||||
* chaos is stable between visits); scrolling sweeps each one along a small
|
||||
* arc into its sorted column - exactly what the tool does to a messy folder.
|
||||
* Reduced motion / no JS: the sorted end state, no pin.
|
||||
*/
|
||||
import Eyebrow from '../Eyebrow.astro';
|
||||
import { getLocaleFromUrl, useTranslations } from '../../i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
type Cat = 'img' | 'doc' | 'audio' | 'code';
|
||||
const columns: { cat: Cat; label: string }[] = [
|
||||
{ cat: 'img', label: 'Images' },
|
||||
{ cat: 'doc', label: locale === 'fr' ? 'Documents' : 'Documents' },
|
||||
{ cat: 'audio', label: 'Audio' },
|
||||
{ cat: 'code', label: 'Code' },
|
||||
];
|
||||
|
||||
const files: { name: string; cat: Cat }[] = [
|
||||
{ name: 'IMG_2041.jpg', cat: 'img' },
|
||||
{ name: 'screenshot.png', cat: 'img' },
|
||||
{ name: 'logo.webp', cat: 'img' },
|
||||
{ name: 'scan-04.tiff', cat: 'img' },
|
||||
{ name: 'avatar.gif', cat: 'img' },
|
||||
{ name: 'cv-2026.pdf', cat: 'doc' },
|
||||
{ name: 'notes.md', cat: 'doc' },
|
||||
{ name: 'facture_03.pdf', cat: 'doc' },
|
||||
{ name: 'rapport.docx', cat: 'doc' },
|
||||
{ name: 'todo.txt', cat: 'doc' },
|
||||
{ name: 'demo-take2.wav', cat: 'audio' },
|
||||
{ name: 'podcast.mp3', cat: 'audio' },
|
||||
{ name: 'sample.flac', cat: 'audio' },
|
||||
{ name: 'voice-memo.m4a', cat: 'audio' },
|
||||
{ name: 'main.rs', cat: 'code' },
|
||||
{ name: 'organizer.rs', cat: 'code' },
|
||||
{ name: 'watch.rs', cat: 'code' },
|
||||
{ name: 'Cargo.toml', cat: 'code' },
|
||||
{ name: 'ui.rs', cat: 'code' },
|
||||
{ name: 'fs_rules.rs', cat: 'code' },
|
||||
];
|
||||
---
|
||||
|
||||
<section class="sorter" data-sorter>
|
||||
<div class="stage">
|
||||
<header class="stage-head">
|
||||
<Eyebrow>{t('scene.sorter.eyebrow')}</Eyebrow>
|
||||
<h2>{t('scene.sorter.title')}</h2>
|
||||
</header>
|
||||
|
||||
<div class="board" data-board>
|
||||
{columns.map((col) => (
|
||||
<div class="col" data-col={col.cat}>
|
||||
<p class="col-head" data-col-head>{col.label}</p>
|
||||
{files
|
||||
.filter((f) => f.cat === col.cat)
|
||||
.map((f) => (
|
||||
<span class="chip" data-chip data-cat={f.cat}>
|
||||
<i aria-hidden="true"></i>
|
||||
{f.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p class="hint" aria-hidden="true">{t('scene.hint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { scrub, clamp01 } from '../../lib/scrub';
|
||||
|
||||
/** Deterministic PRNG so the "chaos" looks identical on every visit. */
|
||||
function mulberry32(seed: number) {
|
||||
return () => {
|
||||
seed |= 0;
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
let z = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
z = (z + Math.imul(z ^ (z >>> 7), 61 | z)) ^ z;
|
||||
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function init() {
|
||||
const root = document.querySelector<HTMLElement>('[data-sorter]');
|
||||
if (!root) return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
root.classList.add('static');
|
||||
return;
|
||||
}
|
||||
|
||||
const board = root.querySelector<HTMLElement>('[data-board]')!;
|
||||
const chips = Array.from(root.querySelectorAll<HTMLElement>('[data-chip]'));
|
||||
const heads = Array.from(root.querySelectorAll<HTMLElement>('[data-col-head]'));
|
||||
|
||||
interface Item {
|
||||
el: HTMLElement;
|
||||
// Sorted (target) and scattered (start) positions, board-relative.
|
||||
tx: number;
|
||||
ty: number;
|
||||
sx: number;
|
||||
sy: number;
|
||||
rot: number;
|
||||
delay: number;
|
||||
}
|
||||
let items: Item[] = [];
|
||||
|
||||
const measure = () => {
|
||||
// 1) Let the grid lay chips out sorted, measure targets.
|
||||
root.classList.remove('is-ready');
|
||||
board.style.height = '';
|
||||
chips.forEach((c) => c.removeAttribute('style'));
|
||||
const bRect = board.getBoundingClientRect();
|
||||
board.style.height = `${bRect.height}px`;
|
||||
|
||||
const rng = mulberry32(20260702);
|
||||
items = chips.map((el, i) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const tx = r.left - bRect.left;
|
||||
const ty = r.top - bRect.top;
|
||||
return {
|
||||
el,
|
||||
tx,
|
||||
ty,
|
||||
sx: rng() * (bRect.width - r.width),
|
||||
sy: rng() * (bRect.height - r.height),
|
||||
rot: (rng() - 0.5) * 34,
|
||||
delay: i / chips.length,
|
||||
};
|
||||
});
|
||||
|
||||
// 2) Freeze the board height, absolutize the chips at their scatter.
|
||||
root.classList.add('is-ready');
|
||||
apply(lastP);
|
||||
};
|
||||
|
||||
let lastP = 0;
|
||||
const apply = (p: number) => {
|
||||
lastP = p;
|
||||
for (const it of items) {
|
||||
// Each chip travels inside its own window, staggered by index.
|
||||
const local = clamp01((p - 0.06 - it.delay * 0.5) / 0.36);
|
||||
const e = local * local * (3 - 2 * local);
|
||||
const x = it.sx + (it.tx - it.sx) * e;
|
||||
const y = it.sy + (it.ty - it.sy) * e - Math.sin(e * Math.PI) * 42; // small arc
|
||||
const rot = it.rot * (1 - e);
|
||||
it.el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) rotate(${rot.toFixed(1)}deg)`;
|
||||
it.el.classList.toggle('is-sorted', e > 0.98);
|
||||
}
|
||||
const done = clamp01((p - 0.86) / 0.12);
|
||||
heads.forEach((h) => h.style.setProperty('--done', String(done)));
|
||||
};
|
||||
|
||||
measure();
|
||||
scrub(root, apply);
|
||||
|
||||
let resizeT = 0;
|
||||
window.addEventListener('resize', () => {
|
||||
window.clearTimeout(resizeT);
|
||||
resizeT = window.setTimeout(measure, 150);
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../styles/type' as type;
|
||||
|
||||
.sorter {
|
||||
margin-block: clamp(40px, 8vh, 96px);
|
||||
|
||||
&.is-ready {
|
||||
height: 280vh;
|
||||
}
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-lg);
|
||||
justify-items: center;
|
||||
padding-block: var(--nds-spacing-xl);
|
||||
|
||||
.is-ready & {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100svh;
|
||||
align-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.stage-head {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xs);
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
|
||||
h2 {
|
||||
@include type.text-3xl;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
}
|
||||
|
||||
.board {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--nds-spacing-md);
|
||||
width: min(880px, 100%);
|
||||
padding: var(--nds-spacing-lg);
|
||||
border: 1px dashed var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
background: color-mix(in srgb, var(--nds-surface) 36%, transparent);
|
||||
|
||||
@media (max-width: 700px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.col {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xs);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.col-head {
|
||||
@include type.text-label;
|
||||
color: color-mix(in srgb, var(--nds-text) calc(55% + var(--done, 0) * 45%), transparent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding-bottom: var(--nds-spacing-2xs);
|
||||
border-bottom: 1px solid
|
||||
color-mix(in srgb, var(--nds-accent) calc(var(--done, 0) * 100%), var(--nds-border));
|
||||
transition: color 0.2s linear, border-color 0.2s linear;
|
||||
}
|
||||
|
||||
.chip {
|
||||
@include type.text-sm;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
width: fit-content;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-full);
|
||||
background: var(--nds-surface-2);
|
||||
color: var(--nds-text);
|
||||
font-family: var(--nds-font-mono);
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
&[data-cat='img'] i { background: var(--nds-success-high); }
|
||||
&[data-cat='doc'] i { background: var(--nds-info-high, var(--nds-primary)); }
|
||||
&[data-cat='audio'] i { background: var(--nds-warning-high); }
|
||||
&[data-cat='code'] i { background: var(--nds-accent); }
|
||||
|
||||
&.is-sorted {
|
||||
border-color: color-mix(in srgb, var(--nds-accent) 55%, var(--nds-border));
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced mode: chips leave the flow and are driven by transforms. */
|
||||
.sorter.is-ready .chip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.hint {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-disabled);
|
||||
|
||||
.static & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,910 @@
|
||||
---
|
||||
/**
|
||||
* NDS scene, "la forge" - three scroll-driven acts on one pinned stage:
|
||||
*
|
||||
* Act 1 - Tokens: raw design tokens (real CSS custom properties, shown as
|
||||
* live swatches) scattered in space converge into a neat row.
|
||||
* Act 2 - Assembly: eight REAL NDS components staged one by one in a 3D
|
||||
* tunnel - each arrives from the depth, holds in focus with its
|
||||
* spec panel (category, role, tokens, usage snippet), then flies
|
||||
* past the camera.
|
||||
* Act 3 - The system: the full library snaps into a grid, with the
|
||||
* package stats counting up and the install command typing out.
|
||||
*
|
||||
* Everything is CSS 3D + the shared scrub() helper - no WebGL needed here.
|
||||
* Reduced motion / no JS: the acts render stacked and fully visible.
|
||||
*/
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Pagination,
|
||||
PaginationNumber,
|
||||
Select,
|
||||
SelectOption,
|
||||
TextField,
|
||||
Toggle,
|
||||
} from '@unkn0wndo3s/nova-design-system';
|
||||
import Eyebrow from '../Eyebrow.astro';
|
||||
import { getLocaleFromUrl, useTranslations } from '../../i18n';
|
||||
import { NDS_COMPONENTS, NDS_COMPONENT_COUNT, NDS_PACKAGE, getNdsVersion } from '../../lib/ndsMeta';
|
||||
import type { Localized } from '../../data/projects';
|
||||
|
||||
// Live metadata: component list parsed from the installed package,
|
||||
// version fetched from the npm registry at build time.
|
||||
const ndsVersion = await getNdsVersion();
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
interface Stage {
|
||||
id: string;
|
||||
component: string;
|
||||
category: Localized;
|
||||
title: Localized;
|
||||
desc: Localized;
|
||||
tokens: string[];
|
||||
usage: string;
|
||||
}
|
||||
|
||||
const stages: Stage[] = [
|
||||
{
|
||||
id: 'button',
|
||||
component: '<Button />',
|
||||
category: { fr: 'Actions', en: 'Actions' },
|
||||
title: { fr: 'Quatre intentions, une API.', en: 'Four intents, one API.' },
|
||||
desc: {
|
||||
fr: "primary, secondary, ghost, danger - trois tailles, états focus/disabled gérés, rendu <a> ou <button> selon la prop href.",
|
||||
en: 'primary, secondary, ghost, danger - three sizes, focus/disabled states handled, renders <a> or <button> depending on the href prop.',
|
||||
},
|
||||
tokens: ['--nds-primary', '--nds-on-primary', '--nds-radius-md'],
|
||||
usage: '<Button type="primary" size="md">Déployer</Button>',
|
||||
},
|
||||
{
|
||||
id: 'badge',
|
||||
component: '<Badge />',
|
||||
category: { fr: 'Feedback', en: 'Feedback' },
|
||||
title: { fr: 'Un statut lisible en un regard.', en: 'A status readable at a glance.' },
|
||||
desc: {
|
||||
fr: 'Cinq types sémantiques × deux variantes (soft/solid). Les couleurs viennent des paires de tokens statut, jamais de valeurs en dur.',
|
||||
en: 'Five semantic types × two variants (soft/solid). Colors come from the status token pairs, never hard-coded values.',
|
||||
},
|
||||
tokens: ['--nds-success-high', '--nds-warning-high', '--nds-error-high'],
|
||||
usage: '<Badge type="success">En production</Badge>',
|
||||
},
|
||||
{
|
||||
id: 'toggle',
|
||||
component: '<Toggle />',
|
||||
category: { fr: 'Formulaires', en: 'Forms' },
|
||||
title: { fr: 'Un vrai Web Component.', en: 'A real Web Component.' },
|
||||
desc: {
|
||||
fr: "Encapsulé en custom element : état interne, événements, accessibilité clavier - utilisable même hors d'Astro.",
|
||||
en: 'Wrapped as a custom element: internal state, events, keyboard accessibility - usable even outside Astro.',
|
||||
},
|
||||
tokens: ['--nds-primary', '--nds-surface-2', '--nds-radius-full'],
|
||||
usage: '<Toggle id="notifications" data-checked="true" />',
|
||||
},
|
||||
{
|
||||
id: 'textfield',
|
||||
component: '<TextField />',
|
||||
category: { fr: 'Formulaires', en: 'Forms' },
|
||||
title: { fr: 'Label, aide, compteur, erreurs.', en: 'Label, help, counter, errors.' },
|
||||
desc: {
|
||||
fr: 'Compteur de caractères optionnel, messages liés en aria-describedby, styles de focus et d’erreur issus des tokens.',
|
||||
en: 'Optional character counter, messages linked via aria-describedby, focus and error styles driven by tokens.',
|
||||
},
|
||||
tokens: ['--nds-border', '--nds-text', '--nds-error-high'],
|
||||
usage: '<TextField id="email" label="E-mail" type="email" />',
|
||||
},
|
||||
{
|
||||
id: 'select',
|
||||
component: '<Select />',
|
||||
category: { fr: 'Formulaires', en: 'Forms' },
|
||||
title: { fr: 'Un select stylable, enfin.', en: 'A styleable select, at last.' },
|
||||
desc: {
|
||||
fr: 'Listbox custom : navigation clavier, fermeture extérieure, valeur synchronisée dans un input caché pour les formulaires natifs.',
|
||||
en: 'Custom listbox: keyboard navigation, outside-close, value mirrored into a hidden input for native forms.',
|
||||
},
|
||||
tokens: ['--nds-surface', '--nds-primary', '--nds-shadow-md'],
|
||||
usage: '<Select default="TypeScript">…</Select>',
|
||||
},
|
||||
{
|
||||
id: 'checkbox',
|
||||
component: '<Checkbox />',
|
||||
category: { fr: 'Formulaires', en: 'Forms' },
|
||||
title: { fr: 'Coche native, peau custom.', en: 'Native check, custom skin.' },
|
||||
desc: {
|
||||
fr: "L'input natif reste dans le DOM (accessibilité et formulaires gratuits) ; seul le visuel est redessiné avec les tokens.",
|
||||
en: 'The native input stays in the DOM (free accessibility and forms); only the visual layer is redrawn with tokens.',
|
||||
},
|
||||
tokens: ['--nds-primary', '--nds-border', '--nds-radius-sm'],
|
||||
usage: '<Checkbox id="cgu" checked />',
|
||||
},
|
||||
{
|
||||
id: 'avatar',
|
||||
component: '<Avatar />',
|
||||
category: { fr: 'Data display', en: 'Data display' },
|
||||
title: { fr: 'Photo, sinon initiales.', en: 'Photo, else initials.' },
|
||||
desc: {
|
||||
fr: "Fallback automatique : si l'image manque ou casse, les initiales prennent le relais - le layout ne bouge pas d'un pixel.",
|
||||
en: 'Automatic fallback: if the image is missing or breaks, initials take over - the layout never shifts a pixel.',
|
||||
},
|
||||
tokens: ['--nds-surface-2', '--nds-text', '--nds-radius-full'],
|
||||
usage: '<Avatar type="initials" name="Louis Potevin" />',
|
||||
},
|
||||
{
|
||||
id: 'pagination',
|
||||
component: '<Pagination />',
|
||||
category: { fr: 'Navigation', en: 'Navigation' },
|
||||
title: { fr: 'Composable, pas configurable.', en: 'Composable, not configurable.' },
|
||||
desc: {
|
||||
fr: 'Pas de prop géante : des sous-composants (PaginationNumber…) à assembler - le pattern de composition utilisé partout dans NDS.',
|
||||
en: 'No giant prop bag: sub-components (PaginationNumber…) you compose - the composition pattern used across NDS.',
|
||||
},
|
||||
tokens: ['--nds-primary', '--nds-neutral', '--nds-radius-md'],
|
||||
usage: '<Pagination><PaginationNumber>1</PaginationNumber>…</Pagination>',
|
||||
},
|
||||
];
|
||||
|
||||
// Act 1: the raw material - real tokens rendered as live swatches.
|
||||
const tokenField: { name: string; kind: 'color' | 'radius' | 'space' | 'type' }[] = [
|
||||
{ name: '--nds-primary', kind: 'color' },
|
||||
{ name: '--nds-accent', kind: 'color' },
|
||||
{ name: '--nds-success-high', kind: 'color' },
|
||||
{ name: '--nds-warning-high', kind: 'color' },
|
||||
{ name: '--nds-error-high', kind: 'color' },
|
||||
{ name: '--nds-surface', kind: 'color' },
|
||||
{ name: '--nds-text', kind: 'color' },
|
||||
{ name: '--nds-neutral', kind: 'color' },
|
||||
{ name: '--nds-radius-md', kind: 'radius' },
|
||||
{ name: '--nds-radius-full', kind: 'radius' },
|
||||
{ name: '--nds-spacing-md', kind: 'space' },
|
||||
{ name: '--nds-spacing-xl', kind: 'space' },
|
||||
{ name: '--nds-font-mono', kind: 'type' },
|
||||
{ name: '--nds-font-sans', kind: 'type' },
|
||||
];
|
||||
// Deterministic scatter (build-time constants, so SSR output is stable).
|
||||
const scatter = [
|
||||
[-38, -26], [32, -34], [-12, 30], [44, 18], [-46, 6], [8, -18],
|
||||
[24, 34], [-28, -8], [40, -6], [-6, -38], [16, 12], [-40, 28],
|
||||
[0, 22], [36, 2],
|
||||
];
|
||||
|
||||
const library = NDS_COMPONENTS;
|
||||
|
||||
const actLabels: Record<string, Localized> = {
|
||||
a1: { fr: 'Acte I - Tout part des tokens.', en: 'Act I - Everything starts with tokens.' },
|
||||
a2: { fr: 'Acte II - Les composants, forgés un à un.', en: 'Act II - The components, forged one by one.' },
|
||||
a3: { fr: 'Acte III - Le système.', en: 'Act III - The system.' },
|
||||
};
|
||||
---
|
||||
|
||||
<section
|
||||
class="forge"
|
||||
data-forge
|
||||
style={`--stages: ${stages.length}`}
|
||||
data-a2={actLabels.a2![locale]}
|
||||
data-a3={actLabels.a3![locale]}
|
||||
aria-label={t('nds.showcase.title')}
|
||||
>
|
||||
<div class="stage-pin">
|
||||
<header class="head">
|
||||
<Eyebrow>{t('nds.showcase.eyebrow')}</Eyebrow>
|
||||
<h2 class="section-title">{t('nds.showcase.title')}</h2>
|
||||
<p class="act-label" data-act-label>{actLabels.a1![locale]}</p>
|
||||
</header>
|
||||
|
||||
<!-- ACT I · token field -->
|
||||
<div class="act act--tokens" data-act1>
|
||||
{tokenField.map((tk, i) => (
|
||||
<span
|
||||
class:list={['tk', `tk--${tk.kind}`]}
|
||||
style={`--dx:${scatter[i]![0]}vmin; --dy:${scatter[i]![1]}vmin; --i:${i};`}
|
||||
>
|
||||
{tk.kind === 'color' && <i style={`background: var(${tk.name})`}></i>}
|
||||
{tk.kind === 'radius' && <i style={`border-radius: var(${tk.name})`}></i>}
|
||||
{tk.kind === 'space' && <i style={`width: var(${tk.name})`}></i>}
|
||||
{tk.kind === 'type' && <i>Aa</i>}
|
||||
<code>{tk.name}</code>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- ACT II · the tunnel of components -->
|
||||
<div class="act act--tunnel" data-act2>
|
||||
{stages.map((s, i) => (
|
||||
<article class="slide" data-slide={i} aria-labelledby={`forge-${s.id}`}>
|
||||
<p class="slide__ghost" aria-hidden="true">{s.component}</p>
|
||||
|
||||
<div class="slide__grid">
|
||||
<div class="slide__demo">
|
||||
<div class="demo-halo" aria-hidden="true"></div>
|
||||
{s.id === 'button' && (
|
||||
<div class="demo-row">
|
||||
<Button size="md">Primary</Button>
|
||||
<Button type="secondary" size="md">Secondary</Button>
|
||||
<Button type="ghost" size="md">Ghost</Button>
|
||||
<Button type="danger" size="md">Danger</Button>
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'badge' && (
|
||||
<div class="demo-row">
|
||||
<Badge type="primary">Primary</Badge>
|
||||
<Badge type="success">Success</Badge>
|
||||
<Badge type="warning" variant="solid">Warning</Badge>
|
||||
<Badge type="error">Error</Badge>
|
||||
<Badge type="info" variant="solid">Info</Badge>
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'toggle' && (
|
||||
<div class="demo-row demo-row--big">
|
||||
<Toggle id="forge-toggle" data-checked="true" />
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'textfield' && (
|
||||
<div class="demo-col">
|
||||
<TextField id="forge-tf" label="E-mail" type="email" placeholder="vous@exemple.dev" />
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'select' && (
|
||||
<div class="demo-col">
|
||||
<Select default="TypeScript">
|
||||
<SelectOption value="TypeScript">TypeScript</SelectOption>
|
||||
<SelectOption value="Rust">Rust</SelectOption>
|
||||
<SelectOption value="Python">Python</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'checkbox' && (
|
||||
<div class="demo-row">
|
||||
<Checkbox id="forge-cb" checked />
|
||||
<Checkbox id="forge-cb2" />
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'avatar' && (
|
||||
<div class="demo-row">
|
||||
<Avatar type="initials" name="Louis Potevin" />
|
||||
<Avatar type="initials" name="Nova Design" />
|
||||
</div>
|
||||
)}
|
||||
{s.id === 'pagination' && (
|
||||
<div class="demo-row">
|
||||
<Pagination>
|
||||
<PaginationNumber>1</PaginationNumber>
|
||||
<PaginationNumber>2</PaginationNumber>
|
||||
<PaginationNumber>3</PaginationNumber>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="slide__spec">
|
||||
<p class="spec-cat">{s.category[locale]}</p>
|
||||
<h3 id={`forge-${s.id}`}>{s.title[locale]}</h3>
|
||||
<p class="spec-desc">{s.desc[locale]}</p>
|
||||
|
||||
<ul class="spec-tokens">
|
||||
{s.tokens.map((tok) => (
|
||||
<li>
|
||||
<i style={`background: var(${tok}, transparent)`}></i>
|
||||
<code>{tok}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div class="spec-code" aria-label="Usage">
|
||||
<div class="spec-code__bar"><span></span><span></span><span></span><em>usage.astro</em></div>
|
||||
<pre><code>{s.usage}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
<p class="counter" aria-hidden="true">
|
||||
<span data-counter>01</span> / {String(stages.length).padStart(2, '0')}
|
||||
</p>
|
||||
<ol class="ticks" aria-hidden="true">
|
||||
{stages.map((_, i) => <li data-tick={i}></li>)}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- ACT III · the system -->
|
||||
<div class="act act--finale" data-act3>
|
||||
<ul class="lib">
|
||||
{library.map((name, i) => (
|
||||
<li style={`--i:${i}`}><code>{name}</code></li>
|
||||
))}
|
||||
</ul>
|
||||
<div class="stats">
|
||||
<p><strong data-count-to={NDS_COMPONENT_COUNT}>0</strong><span>{locale === 'fr' ? 'composants' : 'components'}</span></p>
|
||||
<p><strong>v{ndsVersion}</strong><span>{locale === 'fr' ? 'dernière version npm' : 'latest npm version'}</span></p>
|
||||
</div>
|
||||
<p class="install"><code data-typed>{`npm install ${NDS_PACKAGE}`}</code></p>
|
||||
</div>
|
||||
|
||||
<p class="hint" aria-hidden="true">{t('nds.showcase.hint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
import { scrub, clamp01, window01 } from '../../lib/scrub';
|
||||
import { getSmooth } from '../../lib/smooth';
|
||||
|
||||
function init() {
|
||||
const root = document.querySelector<HTMLElement>('[data-forge]');
|
||||
if (!root) return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
root.classList.add('static');
|
||||
return;
|
||||
}
|
||||
root.classList.add('is-ready');
|
||||
|
||||
const actLabel = root.querySelector<HTMLElement>('[data-act-label]')!;
|
||||
const labelTexts = [
|
||||
actLabel.textContent ?? '',
|
||||
root.dataset.a2 ?? '',
|
||||
root.dataset.a3 ?? '',
|
||||
];
|
||||
const act1 = root.querySelector<HTMLElement>('[data-act1]')!;
|
||||
const act2 = root.querySelector<HTMLElement>('[data-act2]')!;
|
||||
const act3 = root.querySelector<HTMLElement>('[data-act3]')!;
|
||||
const slides = Array.from(root.querySelectorAll<HTMLElement>('[data-slide]'));
|
||||
const ticks = Array.from(root.querySelectorAll<HTMLElement>('[data-tick]'));
|
||||
const counter = root.querySelector<HTMLElement>('[data-counter]')!;
|
||||
const counts = Array.from(root.querySelectorAll<HTMLElement>('[data-count-to]'));
|
||||
const typed = root.querySelector<HTMLElement>('[data-typed]')!;
|
||||
const typedFull = typed.textContent ?? '';
|
||||
const n = slides.length;
|
||||
|
||||
// Act windows on the section's 0..1 progress.
|
||||
const A1 = [0.0, 0.14] as const;
|
||||
const A2 = [0.14, 0.86] as const;
|
||||
const A3 = [0.86, 1.0] as const;
|
||||
|
||||
let lastIdx = -1;
|
||||
let lastLabel = -1;
|
||||
let lastState = { p: 0, idx: 0, dwellT: 0.5, inTunnel: 0 };
|
||||
|
||||
scrub(root, (p) => {
|
||||
// ---- Act I: scattered tokens converge, then hand over. ----
|
||||
const gather = window01(p, 0.02, 0.1);
|
||||
const a1out = 1 - window01(p, A1[1] - 0.03, A1[1] + 0.02);
|
||||
act1.style.opacity = String(a1out);
|
||||
act1.style.setProperty('--gather', String(gather));
|
||||
act1.style.visibility = a1out > 0.01 ? 'visible' : 'hidden';
|
||||
|
||||
// ---- Act II: the tunnel. ----
|
||||
const inTunnel = window01(p, A1[1] - 0.02, A1[1] + 0.03) * (1 - window01(p, A2[1] - 0.02, A2[1] + 0.02));
|
||||
act2.style.opacity = String(inTunnel);
|
||||
act2.style.visibility = inTunnel > 0.01 ? 'visible' : 'hidden';
|
||||
|
||||
const tp = clamp01((p - A2[0]) / (A2[1] - A2[0])); // 0..1 inside act II
|
||||
const f0 = tp * n;
|
||||
const idx = Math.min(n - 1, Math.floor(f0));
|
||||
// Dwell: each slide travels in, HOLDS pin-sharp at the center for the
|
||||
// middle of its window, then leaves - small scrolls don't blur it.
|
||||
const dwellT = f0 - idx;
|
||||
const f = idx + (0.5 * window01(dwellT, 0, 0.38) + 0.5 * window01(dwellT, 0.62, 1));
|
||||
lastState = { p, idx, dwellT, inTunnel };
|
||||
|
||||
slides.forEach((el, i) => {
|
||||
const d = f - i - 0.5; // 0 when slide i is centered
|
||||
// depth: future slides far away (negative z), past ones fly at the camera
|
||||
const z = -d * 780;
|
||||
const op = clamp01(1 - Math.abs(d) * 1.7);
|
||||
const blur = Math.min(14, Math.abs(d) * 16);
|
||||
const rot = d * -7;
|
||||
el.style.opacity = String(op);
|
||||
el.style.visibility = op > 0.01 && inTunnel > 0.01 ? 'visible' : 'hidden';
|
||||
el.style.transform = `translate(-50%, -50%) perspective(1100px) translateZ(${z.toFixed(0)}px) rotateY(${rot.toFixed(2)}deg)`;
|
||||
el.style.filter = `blur(${blur.toFixed(1)}px)`;
|
||||
el.classList.toggle('is-front', Math.abs(d) < 0.28);
|
||||
});
|
||||
|
||||
if (idx !== lastIdx && inTunnel > 0.2) {
|
||||
lastIdx = idx;
|
||||
counter.textContent = String(idx + 1).padStart(2, '0');
|
||||
ticks.forEach((tk, i) => tk.classList.toggle('is-on', i <= idx));
|
||||
}
|
||||
|
||||
// ---- Act III: the library grid + stats. ----
|
||||
const fin = window01(p, A3[0] - 0.01, A3[0] + 0.06);
|
||||
act3.style.opacity = String(fin);
|
||||
act3.style.visibility = fin > 0.01 ? 'visible' : 'hidden';
|
||||
act3.style.setProperty('--fin', String(fin));
|
||||
const count = window01(p, A3[0], A3[0] + 0.1);
|
||||
counts.forEach((c) => {
|
||||
c.textContent = String(Math.round(Number(c.dataset.countTo) * count));
|
||||
});
|
||||
const chars = Math.round(typedFull.length * window01(p, A3[0] + 0.02, A3[0] + 0.11));
|
||||
typed.textContent = typedFull.slice(0, Math.max(0, chars)) + (chars < typedFull.length && fin > 0 ? '▌' : '');
|
||||
|
||||
// ---- Act label. ----
|
||||
const which = p < A1[1] ? 0 : p < A2[1] ? 1 : 2;
|
||||
if (which !== lastLabel) {
|
||||
lastLabel = which;
|
||||
actLabel.style.opacity = '0';
|
||||
window.setTimeout(() => {
|
||||
actLabel.textContent = labelTexts[which]!;
|
||||
actLabel.style.opacity = '1';
|
||||
}, 160);
|
||||
}
|
||||
});
|
||||
|
||||
// Idle snap: if scrolling pauses while a component is half in/out, glide
|
||||
// to the position where it sits pin-sharp. Soft lock - any new scroll
|
||||
// input takes over instantly.
|
||||
let idle = 0;
|
||||
let snapped = true;
|
||||
let lastT = performance.now();
|
||||
const snapLoop = (now: number) => {
|
||||
const dt = Math.min((now - lastT) / 1000, 0.05);
|
||||
lastT = now;
|
||||
const lenis = getSmooth();
|
||||
const v = lenis?.velocity ?? 0;
|
||||
if (Math.abs(v) > 1.5) {
|
||||
idle = 0;
|
||||
snapped = false;
|
||||
} else {
|
||||
idle += dt;
|
||||
}
|
||||
if (!snapped && idle > 0.35 && lenis && lastState.inTunnel > 0.5) {
|
||||
const off = Math.abs(lastState.dwellT - 0.5);
|
||||
if (off > 0.03 && off < 0.47) {
|
||||
snapped = true;
|
||||
const rect = root.getBoundingClientRect();
|
||||
const top = lenis.scroll + rect.top;
|
||||
const total = rect.height - window.innerHeight;
|
||||
const pTarget = A2[0] + ((lastState.idx + 0.5) / n) * (A2[1] - A2[0]);
|
||||
lenis.scrollTo(top + pTarget * total, { duration: 0.8 });
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(snapLoop);
|
||||
};
|
||||
requestAnimationFrame(snapLoop);
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../../styles/type' as type;
|
||||
|
||||
.forge {
|
||||
margin-block: clamp(48px, 9vh, 110px);
|
||||
|
||||
&.is-ready {
|
||||
/* runway: intro + one beat per stage + finale */
|
||||
height: calc(140vh + var(--stages) * 85vh + 140vh);
|
||||
}
|
||||
}
|
||||
|
||||
.stage-pin {
|
||||
position: relative;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--nds-spacing-lg);
|
||||
padding-block: var(--nds-spacing-xl);
|
||||
|
||||
.is-ready & {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100svh;
|
||||
overflow: hidden;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
}
|
||||
|
||||
.head {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--nds-spacing-xs);
|
||||
text-align: center;
|
||||
z-index: 4;
|
||||
}
|
||||
.act-label {
|
||||
@include type.text-label;
|
||||
color: var(--nds-accent);
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
transition: opacity 0.16s ease;
|
||||
min-height: 1.4em;
|
||||
}
|
||||
|
||||
/* ------------------------------- ACT I ---------------------------------- */
|
||||
.act--tokens {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
max-width: 760px;
|
||||
|
||||
.is-ready & {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
max-width: none;
|
||||
align-content: center;
|
||||
padding-inline: 10vw;
|
||||
}
|
||||
}
|
||||
|
||||
.tk {
|
||||
@include type.text-sm;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
padding: 5px 12px;
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-full);
|
||||
background: color-mix(in srgb, var(--nds-surface) 85%, transparent);
|
||||
font-family: var(--nds-font-mono);
|
||||
color: var(--nds-neutral);
|
||||
|
||||
i {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
font-style: normal;
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
&.tk--space i {
|
||||
height: 4px;
|
||||
background: var(--nds-primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
&.tk--radius i {
|
||||
border: 1.5px solid var(--nds-accent);
|
||||
}
|
||||
|
||||
/* scattered → gathered, driven by --gather */
|
||||
.is-ready & {
|
||||
transform: translate(
|
||||
calc(var(--dx) * (1 - var(--gather, 0))),
|
||||
calc(var(--dy) * (1 - var(--gather, 0)))
|
||||
)
|
||||
rotate(calc((1 - var(--gather, 0)) * (var(--i) - 7) * 4deg));
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------- ACT II --------------------------------- */
|
||||
.act--tunnel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.is-ready & {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.slide {
|
||||
/* static: stacked list */
|
||||
position: relative;
|
||||
margin-inline: auto;
|
||||
margin-block: var(--nds-spacing-2xl);
|
||||
width: min(980px, 92vw);
|
||||
|
||||
.is-ready & {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
will-change: transform, opacity, filter;
|
||||
pointer-events: none;
|
||||
|
||||
&.is-front {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.slide__ghost {
|
||||
@include type.text-5xl;
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: -0.9em;
|
||||
text-align: center;
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: clamp(3rem, 11vw, 8rem);
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 1px color-mix(in srgb, var(--nds-primary) 26%, transparent);
|
||||
letter-spacing: -0.02em;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slide__grid {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 1fr;
|
||||
gap: clamp(var(--nds-spacing-lg), 4vw, var(--nds-spacing-2xl));
|
||||
align-items: center;
|
||||
padding: clamp(var(--nds-spacing-lg), 3.5vw, var(--nds-spacing-2xl));
|
||||
border: 1px solid color-mix(in srgb, var(--nds-border) 85%, transparent);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
background: color-mix(in srgb, var(--nds-background) 72%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: var(--nds-shadow-md);
|
||||
|
||||
@media (max-width: 840px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.slide__demo {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 180px;
|
||||
border: 1px dashed color-mix(in srgb, var(--nds-primary) 35%, transparent);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
padding: var(--nds-spacing-lg);
|
||||
}
|
||||
.demo-halo {
|
||||
position: absolute;
|
||||
inset: 20%;
|
||||
background: radial-gradient(closest-side, color-mix(in srgb, var(--nds-primary) 18%, transparent), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.demo-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
}
|
||||
.demo-row--big {
|
||||
scale: 1.5;
|
||||
}
|
||||
.demo-col {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-sm);
|
||||
width: min(300px, 100%);
|
||||
}
|
||||
|
||||
.slide__spec {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-sm);
|
||||
justify-items: start;
|
||||
|
||||
h3 {
|
||||
@include type.text-2xl;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
}
|
||||
.spec-cat {
|
||||
@include type.text-label;
|
||||
color: var(--nds-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.spec-desc {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-neutral);
|
||||
max-width: 46ch;
|
||||
}
|
||||
.spec-tokens {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
@include type.text-sm;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-full);
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--nds-neutral);
|
||||
|
||||
i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.spec-code {
|
||||
width: 100%;
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-md);
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--nds-surface) 92%, transparent);
|
||||
|
||||
&__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--nds-border);
|
||||
|
||||
span {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--nds-border);
|
||||
}
|
||||
span:first-child { background: var(--nds-error-high); }
|
||||
span:nth-child(2) { background: var(--nds-warning-high); }
|
||||
span:nth-child(3) { background: var(--nds-success-high); }
|
||||
em {
|
||||
@include type.text-sm;
|
||||
margin-left: auto;
|
||||
font-style: normal;
|
||||
font-size: 0.7rem;
|
||||
color: var(--nds-disabled);
|
||||
font-family: var(--nds-font-mono);
|
||||
}
|
||||
}
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: var(--nds-spacing-sm) var(--nds-spacing-md);
|
||||
overflow-x: auto;
|
||||
}
|
||||
code {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--nds-primary);
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
.counter {
|
||||
@include type.text-label;
|
||||
position: absolute;
|
||||
right: clamp(16px, 4vw, 48px);
|
||||
bottom: clamp(14px, 3vh, 32px);
|
||||
margin: 0;
|
||||
font-family: var(--nds-font-mono);
|
||||
color: var(--nds-neutral);
|
||||
display: none;
|
||||
|
||||
span {
|
||||
@include type.text-3xl;
|
||||
color: var(--nds-accent);
|
||||
}
|
||||
.is-ready & { display: block; }
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: absolute;
|
||||
left: clamp(16px, 4vw, 48px);
|
||||
bottom: clamp(18px, 3vh, 36px);
|
||||
display: none;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--nds-border);
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&.is-on { background: var(--nds-accent); }
|
||||
}
|
||||
.is-ready & { display: flex; }
|
||||
}
|
||||
|
||||
/* ------------------------------- ACT III -------------------------------- */
|
||||
.act--finale {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: var(--nds-spacing-lg);
|
||||
width: min(880px, 94vw);
|
||||
|
||||
.is-ready & {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
width: auto;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
padding-inline: 8vw;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lib {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-width: 760px;
|
||||
|
||||
li {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-full);
|
||||
background: color-mix(in srgb, var(--nds-surface) 80%, transparent);
|
||||
|
||||
code {
|
||||
font-family: var(--nds-font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.is-ready & {
|
||||
opacity: calc(var(--fin, 0) * 1);
|
||||
transform: translateY(calc((1 - var(--fin, 0)) * (12px + var(--i) * 1.2px)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: clamp(var(--nds-spacing-lg), 6vw, var(--nds-spacing-2xl));
|
||||
|
||||
p {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
strong {
|
||||
@include type.text-4xl;
|
||||
font-family: var(--nds-font-mono);
|
||||
color: var(--nds-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
span {
|
||||
@include type.text-label;
|
||||
color: var(--nds-neutral);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
}
|
||||
|
||||
.install {
|
||||
code {
|
||||
@include type.text-sm;
|
||||
display: inline-block;
|
||||
padding: var(--nds-spacing-xs) var(--nds-spacing-lg);
|
||||
border: 1px solid color-mix(in srgb, var(--nds-accent) 50%, var(--nds-border));
|
||||
border-radius: var(--nds-radius-md);
|
||||
background: color-mix(in srgb, var(--nds-surface) 92%, transparent);
|
||||
font-family: var(--nds-font-mono);
|
||||
color: var(--nds-accent);
|
||||
min-height: 1.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-disabled);
|
||||
z-index: 4;
|
||||
|
||||
.static &,
|
||||
.forge:not(.is-ready) & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { Locale } from '../i18n';
|
||||
|
||||
/** A string localized in both site languages. */
|
||||
export type Localized = Record<Locale, string>;
|
||||
|
||||
import { NDS_COMPONENT_COUNT } from '../lib/ndsMeta';
|
||||
|
||||
export type ProjectStatus = 'live' | 'maintained' | 'wip';
|
||||
|
||||
export interface ProjectLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
slug: string;
|
||||
name: string;
|
||||
status: ProjectStatus;
|
||||
tagline: Localized;
|
||||
summary: Localized;
|
||||
/** Long-form body, one paragraph per entry. */
|
||||
body: Localized[];
|
||||
highlights: Localized[];
|
||||
stack: { name: string; icon: string }[];
|
||||
links: ProjectLink[];
|
||||
featured: boolean;
|
||||
}
|
||||
|
||||
export const projects: Project[] = [
|
||||
{
|
||||
slug: 'nova-design-system',
|
||||
name: 'Nova Design System',
|
||||
status: 'maintained',
|
||||
featured: true,
|
||||
tagline: {
|
||||
fr: 'Bibliothèque de composants Astro - tokens, accessibilité, distribution npm.',
|
||||
en: 'Astro component library - tokens, accessibility, npm distribution.',
|
||||
},
|
||||
summary: {
|
||||
fr: `${NDS_COMPONENT_COUNT} composants Astro, un système complet de tokens (couleur, typographie, espacement, rayons) et un thème clair/sombre piloté par variables CSS. Publié sur npm, il propulse ce portfolio.`,
|
||||
en: `A library of ${NDS_COMPONENT_COUNT} Astro components, a complete token system (color, typography, spacing, radius) and a light/dark theme driven by CSS variables. Published on npm, it powers this portfolio.`,
|
||||
},
|
||||
body: [
|
||||
{
|
||||
fr: "Nova Design System est né d'un besoin simple : arrêter de réécrire les mêmes boutons, champs et modales d'un projet à l'autre. Chaque composant est développé avec son interface TypeScript, ses états (hover, focus, disabled) et ses styles isolés en SCSS.",
|
||||
en: 'Nova Design System started from a simple need: stop rewriting the same buttons, fields and modals from one project to the next. Every component ships with its TypeScript interface, its states (hover, focus, disabled) and isolated SCSS styles.',
|
||||
},
|
||||
{
|
||||
fr: "La distribution se fait via npm (paquet public `@unkn0wndo3s/nova-design-system`), avec une CI Gitea Actions auto-hébergée qui vérifie, versionne et publie chaque release en 10 à 20 secondes. Le paquet livre ses sources `.astro`/`.scss` brutes, compilées côté consommateur.",
|
||||
en: 'Distribution happens through npm (public package `@unkn0wndo3s/nova-design-system`), with a self-hosted Gitea Actions CI that checks, versions and publishes each release in 10 to 20 seconds. The package ships raw `.astro`/`.scss` sources, compiled on the consumer side.',
|
||||
},
|
||||
{
|
||||
fr: "La suite : adapter progressivement NDS à mes autres projets, y compris hors de l'écosystème Astro - un binding GPUI/Rust est prévu pour File Organizer.",
|
||||
en: 'Next up: progressively adapting NDS to my other projects, including outside the Astro ecosystem - a GPUI/Rust binding is planned for File Organizer.',
|
||||
},
|
||||
],
|
||||
highlights: [
|
||||
{ fr: `${NDS_COMPONENT_COUNT} composants exportés sur npm`, en: `${NDS_COMPONENT_COUNT} components exported on npm` },
|
||||
{ fr: 'Tokens couleur / typo / espacement / rayons, thème clair-sombre', en: 'Color / type / spacing / radius tokens, light-dark theme' },
|
||||
{ fr: 'CI/CD Gitea Actions auto-hébergée, publication npm en ~15 s', en: 'Self-hosted Gitea Actions CI/CD, npm publish in ~15 s' },
|
||||
{ fr: 'Licence maison : usage libre, monétisation du DS interdite', en: 'Custom license: free to use, monetizing NDS itself prohibited' },
|
||||
],
|
||||
stack: [
|
||||
{ name: 'Astro', icon: 'Astro' },
|
||||
{ name: 'TypeScript', icon: 'Typescript' },
|
||||
{ name: 'Sass', icon: 'Sass' },
|
||||
{ name: 'Gitea CI', icon: 'Gitea' },
|
||||
{ name: 'npm', icon: 'Npm' },
|
||||
],
|
||||
links: [
|
||||
{ label: 'npm', url: 'https://www.npmjs.com/package/@unkn0wndo3s/nova-design-system' },
|
||||
{ label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn/nova-design-system' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'nova-infra',
|
||||
name: 'Self-hosted Infrastructure',
|
||||
status: 'live',
|
||||
featured: true,
|
||||
tagline: {
|
||||
fr: 'Services conteneurisés, CI/CD et ingress sécurisé sur serveur dédié.',
|
||||
en: 'Containerized services, CI/CD and secured ingress on a dedicated server.',
|
||||
},
|
||||
summary: {
|
||||
fr: "Infrastructure auto-hébergée sur serveur OVH : forge Git (Gitea), gestionnaire de secrets (Vaultwarden), runners CI et sites hébergés, exposés derrière un reverse proxy Apache et Cloudflare.",
|
||||
en: 'Self-hosted infrastructure on an OVH server: Git forge (Gitea), secret manager (Vaultwarden), CI runners and hosted sites, exposed behind an Apache reverse proxy and Cloudflare.',
|
||||
},
|
||||
body: [
|
||||
{
|
||||
fr: "Tout mon outillage de développement tourne sur ma propre infrastructure : Gitea pour le code et les releases, Vaultwarden pour les secrets, un runner Gitea Actions pour la CI/CD, et les sites de production (dont celui-ci).",
|
||||
en: 'All my development tooling runs on my own infrastructure: Gitea for code and releases, Vaultwarden for secrets, a Gitea Actions runner for CI/CD, and the production sites (including this one).',
|
||||
},
|
||||
{
|
||||
fr: "Chaque service est conteneurisé et isolé ; l'exposition passe par un reverse proxy Apache avec TLS, derrière Cloudflare pour le DNS et la protection. Les déploiements sont entièrement automatisés : un push sur `main` suffit.",
|
||||
en: 'Each service is containerized and isolated; exposure goes through an Apache reverse proxy with TLS, behind Cloudflare for DNS and protection. Deployments are fully automated: a push to `main` is all it takes.',
|
||||
},
|
||||
],
|
||||
highlights: [
|
||||
{ fr: 'Forge Git + CI/CD + secrets 100 % auto-hébergés', en: '100% self-hosted Git forge + CI/CD + secrets' },
|
||||
{ fr: 'Reverse proxy Apache, TLS, DNS et protection Cloudflare', en: 'Apache reverse proxy, TLS, Cloudflare DNS and protection' },
|
||||
{ fr: 'Déploiement continu : push sur main = mise en production', en: 'Continuous deployment: push to main = production release' },
|
||||
],
|
||||
stack: [
|
||||
{ name: 'Docker', icon: 'Docker' },
|
||||
{ name: 'Linux', icon: 'Linux' },
|
||||
{ name: 'Apache', icon: 'Apache' },
|
||||
{ name: 'Gitea', icon: 'Gitea' },
|
||||
{ name: 'Cloudflare', icon: 'Cloudflare' },
|
||||
],
|
||||
links: [{ label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn' }],
|
||||
},
|
||||
{
|
||||
slug: 'portfolio',
|
||||
name: 'louis-potevin.dev',
|
||||
status: 'live',
|
||||
featured: true,
|
||||
tagline: {
|
||||
fr: 'Ce site. Astro multi-pages, fond 3D, bilingue, 100 % Nova Design System.',
|
||||
en: 'This website. Multi-page Astro app, 3D background, bilingual, 100% Nova Design System.',
|
||||
},
|
||||
summary: {
|
||||
fr: "Portfolio statique bilingue (FR/EN), entièrement composé avec les composants Nova, avec une scène spatiale Three.js pilotée par le scroll et un SEO complet (hreflang, JSON-LD, sitemap).",
|
||||
en: 'A bilingual (FR/EN) static portfolio, entirely composed with Nova components, featuring a scroll-driven Three.js space scene and full SEO (hreflang, JSON-LD, sitemap).',
|
||||
},
|
||||
body: [
|
||||
{
|
||||
fr: "Le site sert de vitrine et de banc d'essai au design system : chaque bouton, badge, champ ou fil d'Ariane provient du paquet npm `@unkn0wndo3s/nova-design-system`, sans style dupliqué.",
|
||||
en: 'The site doubles as a showcase and test bench for the design system: every button, badge, field or breadcrumb comes from the `@unkn0wndo3s/nova-design-system` npm package, with no duplicated styling.',
|
||||
},
|
||||
{
|
||||
fr: "Côté 3D, une ceinture d'astéroïdes réagit au scroll sur la page d'accueil et les composants du design system flottent en 3D sur sa page projet. Le tout se coupe proprement sans WebGL ou quand l'utilisateur préfère réduire les animations.",
|
||||
en: 'On the 3D side, an asteroid belt reacts to scroll on the home page and the design system components float in 3D on its project page. Everything degrades cleanly without WebGL or when the user prefers reduced motion.',
|
||||
},
|
||||
],
|
||||
highlights: [
|
||||
{ fr: 'Bilingue FR/EN avec hreflang et sitemap localisé', en: 'Bilingual FR/EN with hreflang and localized sitemap' },
|
||||
{ fr: 'Scène Three.js pilotée par le scroll, fallback sans WebGL', en: 'Scroll-driven Three.js scene, no-WebGL fallback' },
|
||||
{ fr: 'JSON-LD Person / WebSite / BreadcrumbList / SoftwareSourceCode', en: 'Person / WebSite / BreadcrumbList / SoftwareSourceCode JSON-LD' },
|
||||
],
|
||||
stack: [
|
||||
{ name: 'Astro', icon: 'Astro' },
|
||||
{ name: 'TypeScript', icon: 'Typescript' },
|
||||
{ name: 'Sass', icon: 'Sass' },
|
||||
{ name: 'Three.js', icon: 'Threedotjs' },
|
||||
],
|
||||
links: [{ label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn/portfolio' }],
|
||||
},
|
||||
{
|
||||
slug: 'file-organizer',
|
||||
name: 'File Organizer',
|
||||
status: 'wip',
|
||||
featured: false,
|
||||
tagline: {
|
||||
fr: "Portage Java vers Rust d'un organiseur de fichiers, UI native avec GPUI.",
|
||||
en: 'Java-to-Rust port of a file organizer, native UI with GPUI.',
|
||||
},
|
||||
summary: {
|
||||
fr: "Réécriture en Rust d'un outil d'organisation de fichiers initialement en Java : surveillance du système de fichiers avec `notify`, interface native GPUI, et architecture qui sépare strictement la logique fichiers de l'UI.",
|
||||
en: 'A Rust rewrite of a file-organizing tool originally built in Java: filesystem watching with `notify`, a native GPUI interface, and an architecture that strictly separates file logic from the UI.',
|
||||
},
|
||||
body: [
|
||||
{
|
||||
fr: "L'objectif du portage : gagner en performance et en fiabilité, et apprendre Rust sur un vrai projet. La logique métier (règles de tri, déplacement, surveillance) est pure et testable, indépendante de la couche de rendu.",
|
||||
en: 'The goal of the port: gain performance and reliability, and learn Rust on a real project. The business logic (sorting rules, moves, watching) is pure and testable, independent from the rendering layer.',
|
||||
},
|
||||
],
|
||||
highlights: [
|
||||
{ fr: 'Logique filesystem pure, découplée de l’UI', en: 'Pure filesystem logic, decoupled from the UI' },
|
||||
{ fr: 'Surveillance temps réel avec le crate notify', en: 'Real-time watching with the notify crate' },
|
||||
],
|
||||
stack: [
|
||||
{ name: 'Rust', icon: 'Rust' },
|
||||
{ name: 'GPUI', icon: 'Rust' },
|
||||
],
|
||||
links: [{ label: 'GitHub', url: 'https://github.com/unkn0wndo3s/file-organizer' }],
|
||||
},
|
||||
];
|
||||
|
||||
export const featuredProjects = projects.filter((p) => p.featured);
|
||||
|
||||
export function getProject(slug: string): Project | undefined {
|
||||
return projects.find((p) => p.slug === slug);
|
||||
}
|
||||
|
||||
/* ── NDS showcase data (project page "flying components" tour) ─────────────── */
|
||||
|
||||
export interface ShowcaseStage {
|
||||
id: string;
|
||||
component: string;
|
||||
title: Localized;
|
||||
description: Localized;
|
||||
tokens: string[];
|
||||
}
|
||||
|
||||
export const ndsShowcase: ShowcaseStage[] = [
|
||||
{
|
||||
id: 'button',
|
||||
component: 'Button',
|
||||
title: { fr: 'Button - quatre intentions, trois tailles', en: 'Button - four intents, three sizes' },
|
||||
description: {
|
||||
fr: 'Primary, secondary, ghost et danger. Rendu en lien ou en bouton selon la prop href, focus visible au clavier.',
|
||||
en: 'Primary, secondary, ghost and danger. Renders as a link or a button depending on the href prop, visible keyboard focus.',
|
||||
},
|
||||
tokens: ['--nds-primary', '--nds-on-primary', '--nds-radius-md', '--nds-ring'],
|
||||
},
|
||||
{
|
||||
id: 'badge',
|
||||
component: 'Badge',
|
||||
title: { fr: 'Badge - statuts sémantiques', en: 'Badge - semantic statuses' },
|
||||
description: {
|
||||
fr: 'Six types (primary, neutral, success, warning, error, info) en deux variantes, soft et solid, dérivées des tokens de statut.',
|
||||
en: 'Six types (primary, neutral, success, warning, error, info) in two variants, soft and solid, derived from the status tokens.',
|
||||
},
|
||||
tokens: ['--nds-success-medium', '--nds-warning-low', '--nds-radius-full'],
|
||||
},
|
||||
{
|
||||
id: 'textfield',
|
||||
component: 'TextField',
|
||||
title: { fr: 'TextField - saisie avec compteur', en: 'TextField - input with counter' },
|
||||
description: {
|
||||
fr: 'Texte, e-mail, mot de passe ou textarea, avec label lié, placeholder et compteur de caractères optionnel.',
|
||||
en: 'Text, e-mail, password or textarea, with a bound label, placeholder and optional character counter.',
|
||||
},
|
||||
tokens: ['--nds-surface', '--nds-border', '--nds-text', '--nds-ring'],
|
||||
},
|
||||
{
|
||||
id: 'toggle',
|
||||
component: 'Toggle',
|
||||
title: { fr: 'Toggle - Web Component', en: 'Toggle - Web Component' },
|
||||
description: {
|
||||
fr: "Implémenté selon le pattern Web Components : état interne, événements natifs, utilisable hors d'Astro.",
|
||||
en: 'Built on the Web Components pattern: internal state, native events, usable outside Astro.',
|
||||
},
|
||||
tokens: ['--nds-primary', '--nds-surface-2', '--nds-radius-full'],
|
||||
},
|
||||
{
|
||||
id: 'select',
|
||||
component: 'Select',
|
||||
title: { fr: 'Select - liste déroulante accessible', en: 'Select - accessible dropdown' },
|
||||
description: {
|
||||
fr: 'Select et SelectOption composables, navigation clavier, valeur par défaut et état désactivé.',
|
||||
en: 'Composable Select and SelectOption, keyboard navigation, default value and disabled state.',
|
||||
},
|
||||
tokens: ['--nds-surface', '--nds-border-strong', '--nds-shadow-md'],
|
||||
},
|
||||
{
|
||||
id: 'pagination',
|
||||
component: 'Pagination',
|
||||
title: { fr: 'Pagination - navigation par pages', en: 'Pagination - page navigation' },
|
||||
description: {
|
||||
fr: 'Pagination et PaginationNumber composables, état courant et bornes gérés par le composant.',
|
||||
en: 'Composable Pagination and PaginationNumber, current state and bounds handled by the component.',
|
||||
},
|
||||
tokens: ['--nds-primary-soft', '--nds-text', '--nds-radius-md'],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Profile and links - single source of truth for the whole site. */
|
||||
export const site = {
|
||||
name: 'Louis Potevin',
|
||||
email: 'contact@louis-potevin.dev',
|
||||
location: { city: 'Limoges', region: 'Nouvelle-Aquitaine', country: 'FR' },
|
||||
availability: { cdiFrom: '2026-09', freelance: true },
|
||||
links: {
|
||||
github: { label: 'GitHub', url: 'https://github.com/unkn0wndo3s' },
|
||||
gitea: { label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn' },
|
||||
npm: { label: 'npm', url: 'https://www.npmjs.com/~unkn0wndo3s' },
|
||||
linkedin: { label: 'LinkedIn', url: 'https://www.linkedin.com/in/louis-potevin' },
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Everyday tools, shown on the home page. `icon` maps to simple-icons-astro names. */
|
||||
export const stack = [
|
||||
{ name: 'Astro', icon: 'Astro' },
|
||||
{ name: 'Vue.js', icon: 'Vuedotjs' },
|
||||
{ name: 'TypeScript', icon: 'Typescript' },
|
||||
{ name: 'JavaScript', icon: 'Javascript' },
|
||||
{ name: 'Sass', icon: 'Sass' },
|
||||
{ name: 'Node.js', icon: 'Nodedotjs' },
|
||||
{ name: 'PostgreSQL', icon: 'Postgresql' },
|
||||
{ name: 'Docker', icon: 'Docker' },
|
||||
{ name: 'Linux', icon: 'Linux' },
|
||||
{ name: 'Cloudflare', icon: 'Cloudflare' },
|
||||
{ name: 'Gitea CI/CD', icon: 'Gitea' },
|
||||
{ name: 'Rust', icon: 'Rust' },
|
||||
{ name: 'Python', icon: 'Python' },
|
||||
{ name: 'Git', icon: 'Git' },
|
||||
{ name: 'Figma', icon: 'Figma' },
|
||||
] as const;
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
|
||||
// The NDS "./styles" export maps to a raw .scss entry - no type declarations shipped.
|
||||
declare module '@unkn0wndo3s/nova-design-system/styles';
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* i18n core - locale detection, path localization, dictionary access.
|
||||
*
|
||||
* Routing model (Astro i18n, `prefixDefaultLocale: false`):
|
||||
* fr (default) -> / /work /work/[slug] /about /contact
|
||||
* en -> /en/ /en/work ...
|
||||
*/
|
||||
import { ui, type UIKey } from './ui';
|
||||
|
||||
export const LOCALES = ['fr', 'en'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: Locale = 'fr';
|
||||
|
||||
export const LOCALE_LABELS: Record<Locale, string> = { fr: 'Français', en: 'English' };
|
||||
export const OG_LOCALES: Record<Locale, string> = { fr: 'fr_FR', en: 'en_US' };
|
||||
export const HTML_LANGS: Record<Locale, string> = { fr: 'fr', en: 'en' };
|
||||
|
||||
/** Extracts the locale from a pathname ("/en/work" -> "en", "/work" -> "fr"). */
|
||||
export function getLocaleFromUrl(url: URL): Locale {
|
||||
const [, first] = url.pathname.split('/');
|
||||
return (LOCALES as readonly string[]).includes(first) && first !== DEFAULT_LOCALE
|
||||
? (first as Locale)
|
||||
: DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/** Prefixes a root-relative path for the given locale. localizePath('/work', 'en') -> '/en/work' */
|
||||
export function localizePath(path: string, locale: Locale): string {
|
||||
const clean = path.startsWith('/') ? path : `/${path}`;
|
||||
if (locale === DEFAULT_LOCALE) return clean;
|
||||
return clean === '/' ? `/${locale}/` : `/${locale}${clean}`;
|
||||
}
|
||||
|
||||
/** Strips a locale prefix, giving the canonical route shared by all locales. */
|
||||
export function unlocalizePath(pathname: string): string {
|
||||
for (const locale of LOCALES) {
|
||||
if (locale === DEFAULT_LOCALE) continue;
|
||||
if (pathname === `/${locale}` || pathname === `/${locale}/`) return '/';
|
||||
if (pathname.startsWith(`/${locale}/`)) return pathname.slice(locale.length + 1);
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/** Translation accessor bound to a locale: const t = useTranslations('fr'); t('nav.home') */
|
||||
export function useTranslations(locale: Locale) {
|
||||
return function t(key: UIKey): string {
|
||||
return ui[locale][key] ?? ui[DEFAULT_LOCALE][key] ?? key;
|
||||
};
|
||||
}
|
||||
|
||||
/** hreflang alternates for the current page (fr, en, x-default). */
|
||||
export function getAlternates(url: URL, site: string) {
|
||||
const route = unlocalizePath(url.pathname);
|
||||
const abs = (p: string) => new URL(p, site).href;
|
||||
return [
|
||||
{ hreflang: 'fr', href: abs(localizePath(route, 'fr')) },
|
||||
{ hreflang: 'en', href: abs(localizePath(route, 'en')) },
|
||||
{ hreflang: 'x-default', href: abs(localizePath(route, DEFAULT_LOCALE)) },
|
||||
];
|
||||
}
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
import { NDS_COMPONENT_COUNT } from '../lib/ndsMeta';
|
||||
|
||||
/**
|
||||
* UI dictionaries. Keys are grouped by surface (nav, home, work, about, contact, footer).
|
||||
* Project content lives in `src/data/projects.ts` (localized per field).
|
||||
*/
|
||||
const fr = {
|
||||
// Meta
|
||||
'meta.home.title': 'Louis Potevin - Développeur full-stack TypeScript/Node · Limoges & remote',
|
||||
'meta.home.description':
|
||||
"Louis Potevin, développeur web full-stack : front-end, back-end, TypeScript, Astro, Node. Disponible en CDI dès septembre 2026 et en freelance, partout en France ou en remote.",
|
||||
'meta.work.title': 'Projets - Louis Potevin, développeur full-stack',
|
||||
'meta.work.description':
|
||||
"Design system publié sur npm, infrastructure auto-hébergée, outils Rust : les projets de Louis Potevin, développeur web full-stack.",
|
||||
'meta.docs.title': 'Docs - Comment ce portfolio est construit, de A à Z',
|
||||
'meta.docs.description':
|
||||
"Documentation technique complète du portfolio de Louis Potevin : Astro, i18n, Lenis, Three.js, scènes scroll-driven, design system, SEO, déploiement - avec le vrai code source.",
|
||||
'meta.about.title': 'À propos - Louis Potevin, développeur full-stack',
|
||||
'meta.about.description':
|
||||
"Parcours de Louis Potevin : BUT MMI, alternance chez Legrand, projets personnels. Développeur full-stack TypeScript / Node, disponible en CDI et freelance.",
|
||||
'meta.contact.title': 'Contact - Louis Potevin, développeur full-stack',
|
||||
'meta.contact.description':
|
||||
"Contacter Louis Potevin, développeur web full-stack. CDI dès septembre 2026 ou mission freelance, partout en France, sur site ou en remote.",
|
||||
|
||||
// Nav
|
||||
'nav.home': 'Accueil',
|
||||
'nav.work': 'Projets',
|
||||
'nav.docs': "Comment c'est fait",
|
||||
'nav.about': 'À propos',
|
||||
'nav.contact': 'Contact',
|
||||
'nav.cta': 'Me contacter',
|
||||
'nav.skip': 'Aller au contenu',
|
||||
'nav.lang': 'Langue',
|
||||
|
||||
// Availability
|
||||
'avail.badge': 'Disponible',
|
||||
'avail.line': 'CDI dès septembre 2026 · Freelance dès maintenant',
|
||||
'avail.where': 'Partout en France, sur site ou en remote',
|
||||
|
||||
// Home
|
||||
'home.hero.title.role': 'Développeur full-stack',
|
||||
'home.hero.lead':
|
||||
"Deux ans sur une application métier en production chez Legrand, un design system publié sur npm, une infra que j'héberge moi-même. Ce site tourne sur tout ça.",
|
||||
'home.hero.viewProjects': 'Voir les projets',
|
||||
'home.hero.contact': 'Me contacter',
|
||||
|
||||
'home.profile.eyebrow': 'Profil',
|
||||
'home.profile.title': 'Un profil full-stack, à l’aise sur toute la chaîne.',
|
||||
'home.profile.body1':
|
||||
"Depuis avril 2025, je développe une application métier utilisée dans les ateliers de production de Legrand France - stage, puis job d'été, puis alternance, toujours sur le même produit. Du code que de vraies équipes utilisent tous les jours (sous NDA, donc pas de détails ici).",
|
||||
'home.profile.body2':
|
||||
"À côté, je construis mes propres outils : Nova Design System sur npm, un Gitea et sa CI sur mon VPS. Pas pour la ligne de CV - parce que maintenir un truc dans la durée apprend plus que le recommencer.",
|
||||
'home.profile.front.title': 'Front-end',
|
||||
'home.profile.front.body': `Vue.js chez Legrand, Astro sur mes projets, TypeScript partout. Et un design system maison de ${NDS_COMPONENT_COUNT} composants pour ne rien réécrire deux fois.`,
|
||||
'home.profile.back.title': 'Back-end',
|
||||
'home.profile.back.body':
|
||||
'Node et Express en production chez Legrand, PostgreSQL pour les données.',
|
||||
'home.profile.ops.title': 'Outils & déploiement',
|
||||
'home.profile.ops.body':
|
||||
"Mon Gitea, mes runners, mon Apache, mon VPS : quand ce site se déploie, c'est ma CI qui tourne, pas celle de quelqu'un d'autre.",
|
||||
|
||||
'home.projects.eyebrow': 'Projets phares',
|
||||
'home.projects.title': 'Projets',
|
||||
'home.projects.all': 'Tous les projets',
|
||||
'home.projects.view': 'Voir le projet',
|
||||
|
||||
'home.stack.eyebrow': 'Stack technique',
|
||||
'home.stack.title': 'Mes outils du quotidien.',
|
||||
|
||||
'home.cta.title': 'Un poste, une mission, une question ?',
|
||||
'home.cta.body':
|
||||
"CDI full-stack à partir de septembre 2026, freelance dès maintenant. Basé à Limoges, mobile partout en France, à l'aise en remote. Un mail suffit.",
|
||||
'home.cta.contact': 'Me contacter',
|
||||
'home.cta.more': 'En savoir plus',
|
||||
|
||||
// Work
|
||||
'work.title': 'Projets',
|
||||
'work.lead':
|
||||
"Un design system sur npm, une infra auto-hébergée, ce portfolio, et un outil Rust en cours. Le détail, les choix et les ratés de chacun.",
|
||||
'work.status.live': 'En production',
|
||||
'work.status.maintained': 'Maintenu',
|
||||
'work.status.wip': 'En cours',
|
||||
'work.backToList': 'Tous les projets',
|
||||
'work.stack': 'Stack',
|
||||
'work.links': 'Liens',
|
||||
'work.highlights': 'Points clés',
|
||||
'work.next': 'Projet suivant',
|
||||
|
||||
// NDS showcase
|
||||
'nds.showcase.eyebrow': 'Exploration interactive',
|
||||
'nds.showcase.title': 'Les composants, un à un.',
|
||||
'nds.showcase.hint': 'Faites défiler pour parcourir les composants',
|
||||
'nds.showcase.tokens': 'Tokens utilisés',
|
||||
|
||||
// About
|
||||
'about.title': 'À propos',
|
||||
'about.lead':
|
||||
"Développeur full-stack, formé en BUT MMI (parcours développement web) et aguerri en entreprise chez Legrand - du stage à l'alternance, sur la même application métier en production.",
|
||||
'about.exp.eyebrow': 'Expérience',
|
||||
'about.exp.title': 'Parcours',
|
||||
'about.values.eyebrow': 'Méthode',
|
||||
'about.values.title': 'Ma façon de travailler',
|
||||
'about.cv': 'Télécharger mon CV',
|
||||
|
||||
// Contact
|
||||
'contact.title': 'Contact',
|
||||
'contact.lead':
|
||||
'Un poste en CDI, une mission freelance ou simplement une question : écrivez-moi, je réponds vite.',
|
||||
'contact.email.label': 'E-mail',
|
||||
'contact.email.copy': "Copier l'adresse",
|
||||
'contact.email.copied': 'Adresse copiée !',
|
||||
'contact.email.write': 'Écrire un e-mail',
|
||||
'contact.form.name': 'Votre nom',
|
||||
'contact.form.email': 'Votre e-mail',
|
||||
'contact.form.subject': 'Sujet',
|
||||
'contact.form.subject.cdi': 'Opportunité CDI',
|
||||
'contact.form.subject.freelance': 'Mission freelance',
|
||||
'contact.form.subject.other': 'Autre',
|
||||
'contact.form.message': 'Votre message',
|
||||
'contact.form.send': 'Envoyer le message',
|
||||
'contact.form.note':
|
||||
"Le bouton ouvre votre client mail avec le message pré-rempli - aucune donnée n'est stockée sur ce site.",
|
||||
'contact.elsewhere': 'Me trouver ailleurs',
|
||||
|
||||
// Footer
|
||||
'footer.role': 'Développeur full-stack · Limoges · Nouvelle-Aquitaine',
|
||||
'footer.madeWith': 'Construit avec Astro + Nova Design System',
|
||||
|
||||
// Voyage (home cinematic scroll)
|
||||
'voyage.hint': 'Scrollez - le voyage commence',
|
||||
'voyage.progress': 'Progression du voyage',
|
||||
'home.projects.intro.title': 'Un design system, une infra, un outil Rust. Et ce site.',
|
||||
'home.projects.intro.body':
|
||||
'Quatre projets publics, avec leur statut réel - y compris ce qui est encore en chantier.',
|
||||
'home.about.summary':
|
||||
"BUT MMI (dév. web) à l'IUT du Limousin, et deux ans chez Legrand sur la même application métier - du stage à l'alternance.",
|
||||
|
||||
// Docs
|
||||
'docs.eyebrow': 'Documentation',
|
||||
'docs.title': 'Sous le capot.',
|
||||
'docs.lead':
|
||||
"Comment ce site anime le scroll, expliqué avec des démos qui tournent sous vos yeux et le vrai code du dépôt. Si vous lisez tout, vous savez le refaire.",
|
||||
'docs.toc': 'Sommaire',
|
||||
'docs.file': 'Fichier',
|
||||
'docs.why': 'Pourquoi ça marche',
|
||||
'docs.live': 'Démo - ça tourne en vrai',
|
||||
'docs.tryIt': 'Scrollez, regardez',
|
||||
'docs.trap': 'Piège',
|
||||
'docs.reading': 'Lecture ~20 min, molette obligatoire',
|
||||
|
||||
// Project scenes
|
||||
'scene.hint': 'Faites défiler - la scène est pilotée par le scroll',
|
||||
'scene.infra.eyebrow': 'Sous le capot',
|
||||
'scene.infra.title': 'Le trajet d’une requête.',
|
||||
'scene.blueprint.eyebrow': 'Plan de construction',
|
||||
'scene.blueprint.title': 'Du wireframe au produit.',
|
||||
'scene.sorter.eyebrow': 'Démonstration',
|
||||
'scene.sorter.title': 'Du chaos au rangé.',
|
||||
|
||||
// Misc
|
||||
'a11y.langSwitch': 'Changer de langue',
|
||||
'notfound.title': 'Page introuvable',
|
||||
'notfound.body': "Cette page n'existe pas (ou plus). Le reste du site, lui, fonctionne très bien.",
|
||||
'notfound.back': "Retour à l'accueil",
|
||||
} as const;
|
||||
|
||||
const en: Record<UIKey, string> = {
|
||||
'meta.home.title': 'Louis Potevin - Full-stack TypeScript/Node developer · Limoges & remote',
|
||||
'meta.home.description':
|
||||
'Louis Potevin, full-stack web developer: front-end, back-end, TypeScript, Astro, Node. Available for a permanent contract (CDI) from September 2026 and for freelance work, anywhere in France or remote.',
|
||||
'meta.work.title': 'Projects - Louis Potevin, full-stack developer',
|
||||
'meta.work.description':
|
||||
'A design system published on npm, self-hosted infrastructure, Rust tooling: the projects of Louis Potevin, full-stack web developer.',
|
||||
'meta.docs.title': 'Docs - How this portfolio is built, from A to Z',
|
||||
'meta.docs.description':
|
||||
"Complete technical documentation of Louis Potevin's portfolio: Astro, i18n, Lenis, Three.js, scroll-driven scenes, design system, SEO, deployment - with the real source code.",
|
||||
'meta.about.title': 'About - Louis Potevin, full-stack developer',
|
||||
'meta.about.description':
|
||||
"Louis Potevin's background: BUT MMI degree, apprenticeship at Legrand, personal projects. Full-stack TypeScript / Node developer, open to permanent and freelance roles.",
|
||||
'meta.contact.title': 'Contact - Louis Potevin, full-stack developer',
|
||||
'meta.contact.description':
|
||||
'Get in touch with Louis Potevin, full-stack web developer. Permanent contract from September 2026 or freelance work, anywhere in France, on-site or remote.',
|
||||
|
||||
'nav.home': 'Home',
|
||||
'nav.work': 'Projects',
|
||||
'nav.docs': "How it's built",
|
||||
'nav.about': 'About me',
|
||||
'nav.contact': 'Contact',
|
||||
'nav.cta': 'Get in touch',
|
||||
'nav.skip': 'Skip to content',
|
||||
'nav.lang': 'Language',
|
||||
|
||||
'avail.badge': 'Available',
|
||||
'avail.line': 'Permanent contract from September 2026 · Freelance now',
|
||||
'avail.where': 'Anywhere in France, on-site or remote',
|
||||
|
||||
'home.hero.title.role': 'Full-stack developer',
|
||||
'home.hero.lead':
|
||||
'Two years on a business application in production at Legrand, a design system published on npm, infrastructure I host myself. This site runs on all of it.',
|
||||
'home.hero.viewProjects': 'View projects',
|
||||
'home.hero.contact': 'Contact me',
|
||||
|
||||
'home.profile.eyebrow': 'Profile',
|
||||
'home.profile.title': 'A full-stack profile, comfortable across the entire stack.',
|
||||
'home.profile.body1':
|
||||
"Since April 2025 I've been building a business application used in Legrand France's production workshops - internship, then summer job, then apprenticeship, always on the same product. Code that real teams use every day (under NDA, so no details here).",
|
||||
'home.profile.body2':
|
||||
"On the side I build my own tools: Nova Design System on npm, a Gitea and its CI on my VPS. Not for the résumé line - because maintaining something over time teaches more than restarting it.",
|
||||
'home.profile.front.title': 'Front-end',
|
||||
'home.profile.front.body': `Vue.js at Legrand, Astro on my own projects, TypeScript everywhere. Plus a ${NDS_COMPONENT_COUNT}-component in-house design system so nothing gets written twice.`,
|
||||
'home.profile.back.title': 'Back-end',
|
||||
'home.profile.back.body':
|
||||
'Node and Express in production at Legrand, PostgreSQL for the data.',
|
||||
'home.profile.ops.title': 'Tools & deployment',
|
||||
'home.profile.ops.body':
|
||||
"My Gitea, my runners, my Apache, my VPS: when this site deploys, it's my CI running, not someone else's.",
|
||||
|
||||
'home.projects.eyebrow': 'Featured projects',
|
||||
'home.projects.title': 'Projects',
|
||||
'home.projects.all': 'All projects',
|
||||
'home.projects.view': 'View project',
|
||||
|
||||
'home.stack.eyebrow': 'Tech stack',
|
||||
'home.stack.title': 'My everyday tools.',
|
||||
|
||||
'home.cta.title': 'A role, a mission, a question?',
|
||||
'home.cta.body':
|
||||
'Full-stack permanent role from September 2026, freelance right now. Based in Limoges, mobile anywhere in France, comfortable remote. One email is all it takes.',
|
||||
'home.cta.contact': 'Contact me',
|
||||
'home.cta.more': 'Learn more',
|
||||
|
||||
'work.title': 'Projects',
|
||||
'work.lead':
|
||||
'A design system on npm, self-hosted infrastructure, this portfolio, and a Rust tool in progress. The details, choices and missteps of each.',
|
||||
'work.status.live': 'Live',
|
||||
'work.status.maintained': 'Maintained',
|
||||
'work.status.wip': 'In progress',
|
||||
'work.backToList': 'All projects',
|
||||
'work.stack': 'Stack',
|
||||
'work.links': 'Links',
|
||||
'work.highlights': 'Highlights',
|
||||
'work.next': 'Next project',
|
||||
|
||||
'nds.showcase.eyebrow': 'Interactive tour',
|
||||
'nds.showcase.title': 'The components, one by one.',
|
||||
'nds.showcase.hint': 'Scroll to browse the components',
|
||||
'nds.showcase.tokens': 'Tokens in use',
|
||||
|
||||
'about.title': 'About me',
|
||||
'about.lead':
|
||||
'Full-stack developer, trained through a BUT MMI degree (web development track) and battle-tested at Legrand - from internship to apprenticeship, on the same business application in production.',
|
||||
'about.exp.eyebrow': 'Experience',
|
||||
'about.exp.title': 'Background',
|
||||
'about.values.eyebrow': 'Method',
|
||||
'about.values.title': 'How I work',
|
||||
'about.cv': 'Download my resume',
|
||||
|
||||
'contact.title': 'Contact',
|
||||
'contact.lead':
|
||||
'A permanent role, a freelance mission or just a question: write to me, I reply fast.',
|
||||
'contact.email.label': 'E-mail',
|
||||
'contact.email.copy': 'Copy address',
|
||||
'contact.email.copied': 'Address copied!',
|
||||
'contact.email.write': 'Write an e-mail',
|
||||
'contact.form.name': 'Your name',
|
||||
'contact.form.email': 'Your e-mail',
|
||||
'contact.form.subject': 'Subject',
|
||||
'contact.form.subject.cdi': 'Permanent position (CDI)',
|
||||
'contact.form.subject.freelance': 'Freelance mission',
|
||||
'contact.form.subject.other': 'Other',
|
||||
'contact.form.message': 'Your message',
|
||||
'contact.form.send': 'Send message',
|
||||
'contact.form.note':
|
||||
'The button opens your mail client with the message pre-filled - no data is stored on this site.',
|
||||
'contact.elsewhere': 'Find me elsewhere',
|
||||
|
||||
'footer.role': 'Full-stack developer · Limoges · Nouvelle-Aquitaine, France',
|
||||
'footer.madeWith': 'Built with Astro + Nova Design System',
|
||||
|
||||
'voyage.hint': 'Scroll - the journey begins',
|
||||
'voyage.progress': 'Journey progress',
|
||||
'home.projects.intro.title': 'A design system, an infra, a Rust tool. And this site.',
|
||||
'home.projects.intro.body':
|
||||
'Four public projects, with their real status - including what is still under construction.',
|
||||
'home.about.summary':
|
||||
'BUT MMI degree (web dev) at IUT du Limousin, and two years at Legrand on the same business application - from internship to apprenticeship.',
|
||||
|
||||
'docs.eyebrow': 'Documentation',
|
||||
'docs.title': 'Under the hood.',
|
||||
'docs.lead':
|
||||
'How this site animates scrolling, explained with demos running before your eyes and the real code from the repo. Read it all and you can rebuild it.',
|
||||
'docs.toc': 'Contents',
|
||||
'docs.file': 'File',
|
||||
'docs.why': 'Why it works',
|
||||
'docs.live': 'Demo - running for real',
|
||||
'docs.tryIt': 'Scroll and watch',
|
||||
'docs.trap': 'Gotcha',
|
||||
'docs.reading': '~20 min read, scroll wheel required',
|
||||
|
||||
'scene.hint': 'Keep scrolling - the scene is driven by scroll',
|
||||
'scene.infra.eyebrow': 'Under the hood',
|
||||
'scene.infra.title': 'The journey of a request.',
|
||||
'scene.blueprint.eyebrow': 'Blueprint',
|
||||
'scene.blueprint.title': 'From wireframe to product.',
|
||||
'scene.sorter.eyebrow': 'Live demo',
|
||||
'scene.sorter.title': 'From chaos to order.',
|
||||
|
||||
'a11y.langSwitch': 'Switch language',
|
||||
'notfound.title': 'Page not found',
|
||||
'notfound.body': "This page doesn't exist (anymore). The rest of the site works just fine.",
|
||||
'notfound.back': 'Back to home',
|
||||
};
|
||||
|
||||
export type UIKey = keyof typeof fr;
|
||||
export const ui = { fr, en } as const;
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
import '@unkn0wndo3s/nova-design-system/styles';
|
||||
import '../styles/global.scss';
|
||||
|
||||
import SpaceBackground from '../components/SpaceBackground.astro';
|
||||
import SiteNav from '../components/SiteNav.astro';
|
||||
import SiteFooter from '../components/SiteFooter.astro';
|
||||
import { site } from '../data/site';
|
||||
import { personLd, webSiteLd } from '../lib/seo';
|
||||
import {
|
||||
getLocaleFromUrl,
|
||||
getAlternates,
|
||||
useTranslations,
|
||||
HTML_LANGS,
|
||||
OG_LOCALES,
|
||||
LOCALES,
|
||||
} from '../i18n';
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
/** Extra JSON-LD blocks for this page (breadcrumbs, project, profile...). */
|
||||
jsonLd?: object[];
|
||||
/** og:type - "website" for index pages, "article" for project pages. */
|
||||
ogType?: 'website' | 'article';
|
||||
/** Ambient space background canvas (off on pages that mount their own scene). */
|
||||
background?: boolean;
|
||||
/** Full-bleed main (no max-width shell) for cinematic pages. */
|
||||
fullBleed?: boolean;
|
||||
}
|
||||
|
||||
const SITE_URL = 'https://louis-potevin.dev';
|
||||
const { title, description, jsonLd = [], ogType = 'website', background = true, fullBleed = false } = Astro.props;
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
const canonical = new URL(Astro.url.pathname, Astro.site ?? SITE_URL).href;
|
||||
const ogImage = new URL(`/og-${locale}.png`, Astro.site ?? SITE_URL).href;
|
||||
const alternates = getAlternates(Astro.url, (Astro.site ?? new URL(SITE_URL)).href);
|
||||
const otherLocales = LOCALES.filter((l) => l !== locale);
|
||||
|
||||
const ldBlocks = [personLd(locale), webSiteLd(), ...jsonLd];
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={HTML_LANGS[locale]} data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=2" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Intel+One+Mono:wght@400;500;700&display=swap"
|
||||
/>
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="author" content={site.name} />
|
||||
<meta name="robots" content="index, follow, max-image-preview:large" />
|
||||
<link rel="canonical" href={canonical} />
|
||||
{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
|
||||
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:site_name" content={`${site.name} - Portfolio`} />
|
||||
<meta property="og:locale" content={OG_LOCALES[locale]} />
|
||||
{otherLocales.map((l) => <meta property="og:locale:alternate" content={OG_LOCALES[l]} />)}
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content={`${site.name} - ${t('home.hero.title.role')}`} />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
|
||||
<meta name="theme-color" content="#040B0B" />
|
||||
|
||||
{ldBlocks.map((block) => <script is:inline type="application/ld+json" set:html={JSON.stringify(block)} />)}
|
||||
</head>
|
||||
<body>
|
||||
{background && <SpaceBackground />}
|
||||
<a class="skip" href="#content">{t('nav.skip')}</a>
|
||||
<SiteNav />
|
||||
<main id="content" class:list={[{ shell: !fullBleed }]}>
|
||||
<slot />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
|
||||
<script>
|
||||
// Smooth scroll everywhere - every animated surface reads from Lenis,
|
||||
// so all motion sits on one easing curve.
|
||||
import { ensureSmooth } from '../lib/smooth';
|
||||
ensureSmooth();
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.shell {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: 1200px;
|
||||
margin-inline: auto;
|
||||
padding-inline: clamp(20px, 5vw, 56px);
|
||||
}
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
padding: var(--nds-spacing-xs) var(--nds-spacing-md);
|
||||
background: var(--nds-primary);
|
||||
color: var(--nds-on-primary);
|
||||
border-radius: var(--nds-radius-md);
|
||||
&:focus {
|
||||
left: var(--nds-spacing-md);
|
||||
top: var(--nds-spacing-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Brand icon paths, extracted from the installed `simple-icons-astro` package
|
||||
* at build time (`?raw` + regex). Why not use its components directly? Two
|
||||
* validator-breaking issues: the generated <svg> carries a duplicate `fill`
|
||||
* attribute, and each icon embeds a <title> that doubles every name for
|
||||
* screen readers and text extraction ("AstroAstro"). Rendering the raw path
|
||||
* ourselves fixes both, and stays in sync with the package version.
|
||||
*/
|
||||
import apache from '../../node_modules/simple-icons-astro/dist/Apache.astro?raw';
|
||||
import astro from '../../node_modules/simple-icons-astro/dist/Astro.astro?raw';
|
||||
import cloudflare from '../../node_modules/simple-icons-astro/dist/Cloudflare.astro?raw';
|
||||
import docker from '../../node_modules/simple-icons-astro/dist/Docker.astro?raw';
|
||||
import figma from '../../node_modules/simple-icons-astro/dist/Figma.astro?raw';
|
||||
import git from '../../node_modules/simple-icons-astro/dist/Git.astro?raw';
|
||||
import gitea from '../../node_modules/simple-icons-astro/dist/Gitea.astro?raw';
|
||||
import github from '../../node_modules/simple-icons-astro/dist/Github.astro?raw';
|
||||
import javascript from '../../node_modules/simple-icons-astro/dist/Javascript.astro?raw';
|
||||
import linux from '../../node_modules/simple-icons-astro/dist/Linux.astro?raw';
|
||||
import nodedotjs from '../../node_modules/simple-icons-astro/dist/Nodedotjs.astro?raw';
|
||||
import npm from '../../node_modules/simple-icons-astro/dist/Npm.astro?raw';
|
||||
import postgresql from '../../node_modules/simple-icons-astro/dist/Postgresql.astro?raw';
|
||||
import python from '../../node_modules/simple-icons-astro/dist/Python.astro?raw';
|
||||
import rust from '../../node_modules/simple-icons-astro/dist/Rust.astro?raw';
|
||||
import sass from '../../node_modules/simple-icons-astro/dist/Sass.astro?raw';
|
||||
import threedotjs from '../../node_modules/simple-icons-astro/dist/Threedotjs.astro?raw';
|
||||
import typescript from '../../node_modules/simple-icons-astro/dist/Typescript.astro?raw';
|
||||
import vuedotjs from '../../node_modules/simple-icons-astro/dist/Vuedotjs.astro?raw';
|
||||
|
||||
export interface BrandIcon {
|
||||
d: string;
|
||||
brand: string;
|
||||
}
|
||||
|
||||
function parse(raw: string, label: string): BrandIcon {
|
||||
const d = raw.match(/ d="([^"]+)"/)?.[1];
|
||||
const brand = raw.match(/fill="(#[0-9A-Fa-f]{3,8})"/)?.[1] ?? 'currentColor';
|
||||
if (!d) throw new Error(`brandIcons: no path found for ${label}`);
|
||||
return { d, brand };
|
||||
}
|
||||
|
||||
export const BRAND_ICONS: Record<string, BrandIcon> = {
|
||||
Apache: parse(apache, 'Apache'),
|
||||
Astro: parse(astro, 'Astro'),
|
||||
Cloudflare: parse(cloudflare, 'Cloudflare'),
|
||||
Docker: parse(docker, 'Docker'),
|
||||
Figma: parse(figma, 'Figma'),
|
||||
Git: parse(git, 'Git'),
|
||||
Gitea: parse(gitea, 'Gitea'),
|
||||
Github: parse(github, 'Github'),
|
||||
Javascript: parse(javascript, 'Javascript'),
|
||||
Linux: parse(linux, 'Linux'),
|
||||
Nodedotjs: parse(nodedotjs, 'Nodedotjs'),
|
||||
Npm: parse(npm, 'Npm'),
|
||||
Postgresql: parse(postgresql, 'Postgresql'),
|
||||
Python: parse(python, 'Python'),
|
||||
Rust: parse(rust, 'Rust'),
|
||||
Sass: parse(sass, 'Sass'),
|
||||
Threedotjs: parse(threedotjs, 'Threedotjs'),
|
||||
Typescript: parse(typescript, 'Typescript'),
|
||||
Vuedotjs: parse(vuedotjs, 'Vuedotjs'),
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Nova Design System metadata - never hard-coded again.
|
||||
*
|
||||
* - The component list (and therefore the count) is parsed from the installed
|
||||
* package's export index at build time: what npm ships is what we display.
|
||||
* - The version is fetched from the npm registry at build time so the site
|
||||
* shows the LATEST published release even if the local dependency lags;
|
||||
* if the registry is unreachable, we fall back to the installed version.
|
||||
*/
|
||||
import indexRaw from '../../node_modules/@unkn0wndo3s/nova-design-system/src/components/index.ts?raw';
|
||||
import pkgRaw from '../../node_modules/@unkn0wndo3s/nova-design-system/package.json?raw';
|
||||
|
||||
export const NDS_PACKAGE = '@unkn0wndo3s/nova-design-system';
|
||||
|
||||
/** Every exported component name, alphabetical. */
|
||||
export const NDS_COMPONENTS: string[] = [...indexRaw.matchAll(/export \{ default as (\w+) \}/g)]
|
||||
.map((m) => m[1]!)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
export const NDS_COMPONENT_COUNT = NDS_COMPONENTS.length;
|
||||
|
||||
export const NDS_INSTALLED_VERSION: string = (JSON.parse(pkgRaw) as { version: string }).version;
|
||||
|
||||
let cached: string | null = null;
|
||||
|
||||
/** Latest published version (npm registry, build-time), falling back to the installed one. */
|
||||
export async function getNdsVersion(): Promise<string> {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const res = await fetch(`https://registry.npmjs.org/${NDS_PACKAGE}/latest`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) {
|
||||
const { version } = (await res.json()) as { version: string };
|
||||
if (version) {
|
||||
cached = version;
|
||||
return version;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* offline build - installed version is the best truth we have */
|
||||
}
|
||||
cached = NDS_INSTALLED_VERSION;
|
||||
return cached;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Scroll-scrub helper for pinned scenes.
|
||||
*
|
||||
* A scene = a tall section with a sticky 100vh stage inside. This maps the
|
||||
* section's traversal to a 0..1 progress each frame (cheap: only while the
|
||||
* section is near the viewport). Works natively with Lenis since Lenis drives
|
||||
* real window scrolling.
|
||||
*/
|
||||
export function scrub(section: HTMLElement, cb: (p: number) => void): () => void {
|
||||
let raf = 0;
|
||||
let lastP = -1;
|
||||
const loop = () => {
|
||||
const rect = section.getBoundingClientRect();
|
||||
const vh = window.innerHeight;
|
||||
if (rect.bottom > -vh && rect.top < vh * 2) {
|
||||
const total = rect.height - vh;
|
||||
const p = total > 0 ? Math.min(1, Math.max(0, -rect.top / total)) : 1;
|
||||
if (p !== lastP) {
|
||||
lastP = p;
|
||||
cb(p);
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
|
||||
export const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
|
||||
/** Progress remapped to a [a,b] window with smoothstep easing. */
|
||||
export const window01 = (p: number, a: number, b: number) => {
|
||||
const t = clamp01((p - a) / (b - a));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* JSON-LD builders. Each returns a plain object serialized by the layout.
|
||||
* Kept framework-agnostic so it can be unit-tested without Astro.
|
||||
*/
|
||||
import { site } from '../data/site';
|
||||
import type { Project } from '../data/projects';
|
||||
import type { Locale } from '../i18n';
|
||||
|
||||
const SITE_URL = 'https://louis-potevin.dev';
|
||||
|
||||
const PERSON_ID = `${SITE_URL}/#person`;
|
||||
|
||||
export function personLd(locale: Locale) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Person',
|
||||
'@id': PERSON_ID,
|
||||
name: site.name,
|
||||
jobTitle: locale === 'fr' ? 'Développeur full-stack' : 'Full-stack Developer',
|
||||
url: SITE_URL,
|
||||
email: `mailto:${site.email}`,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
addressLocality: site.location.city,
|
||||
addressRegion: site.location.region,
|
||||
addressCountry: site.location.country,
|
||||
},
|
||||
knowsAbout: [
|
||||
'Web development',
|
||||
'TypeScript',
|
||||
'JavaScript',
|
||||
'Astro',
|
||||
'Vue.js',
|
||||
'Node.js',
|
||||
'Sass',
|
||||
'PostgreSQL',
|
||||
'Docker',
|
||||
'CI/CD',
|
||||
'Rust',
|
||||
],
|
||||
knowsLanguage: ['fr', 'en'],
|
||||
sameAs: Object.values(site.links).map((l) => l.url),
|
||||
};
|
||||
}
|
||||
|
||||
export function webSiteLd() {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
'@id': `${SITE_URL}/#website`,
|
||||
name: `${site.name} - Portfolio`,
|
||||
url: SITE_URL,
|
||||
inLanguage: ['fr-FR', 'en-US'],
|
||||
publisher: { '@id': PERSON_ID },
|
||||
};
|
||||
}
|
||||
|
||||
export interface Crumb {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function breadcrumbLd(crumbs: Crumb[]) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: crumbs.map((c, i) => ({
|
||||
'@type': 'ListItem',
|
||||
position: i + 1,
|
||||
name: c.name,
|
||||
item: new URL(c.url, SITE_URL).href,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function projectLd(project: Project, locale: Locale, pageUrl: string) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareSourceCode',
|
||||
name: project.name,
|
||||
description: project.summary[locale],
|
||||
url: pageUrl,
|
||||
codeRepository: project.links[0]?.url,
|
||||
programmingLanguage: project.stack.map((s) => s.name),
|
||||
author: { '@id': PERSON_ID },
|
||||
inLanguage: locale === 'fr' ? 'fr-FR' : 'en-US',
|
||||
};
|
||||
}
|
||||
|
||||
export function profilePageLd(pageUrl: string) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ProfilePage',
|
||||
url: pageUrl,
|
||||
mainEntity: { '@id': PERSON_ID },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Smooth scroll (Lenis), shared as a singleton.
|
||||
*
|
||||
* Everything animated on the site reads scroll from Lenis so motion stays
|
||||
* on a single easing curve - that is what makes scrolling feel like one
|
||||
* continuous animation instead of a page moving.
|
||||
*/
|
||||
import Lenis from 'lenis';
|
||||
|
||||
let lenis: Lenis | null = null;
|
||||
let rafId = 0;
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
/** Idempotent. Returns null when the user prefers reduced motion. */
|
||||
export function ensureSmooth(): Lenis | null {
|
||||
if (prefersReducedMotion()) return null;
|
||||
if (lenis) return lenis;
|
||||
|
||||
lenis = new Lenis({
|
||||
lerp: 0.09,
|
||||
wheelMultiplier: 0.95,
|
||||
// Touch keeps native scrolling; the scenes still read positions each frame.
|
||||
});
|
||||
|
||||
const raf = (time: number) => {
|
||||
lenis!.raf(time);
|
||||
rafId = requestAnimationFrame(raf);
|
||||
};
|
||||
rafId = requestAnimationFrame(raf);
|
||||
|
||||
return lenis;
|
||||
}
|
||||
|
||||
export function getSmooth(): Lenis | null {
|
||||
return lenis;
|
||||
}
|
||||
|
||||
export function destroySmooth(): void {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
lenis?.destroy();
|
||||
lenis = null;
|
||||
rafId = 0;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { Button } from '@unkn0wndo3s/nova-design-system';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
---
|
||||
|
||||
<BaseLayout title={t('notfound.title')} description={t('notfound.body')}>
|
||||
<section class="notfound">
|
||||
<p class="code" aria-hidden="true">404</p>
|
||||
<h1>{t('notfound.title')}</h1>
|
||||
<p class="body">{t('notfound.body')}</p>
|
||||
<Button type="primary" href={localizePath('/', locale)}>{t('notfound.back')}</Button>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.notfound {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: var(--nds-spacing-md);
|
||||
padding-block: var(--nds-spacing-3xl);
|
||||
}
|
||||
|
||||
.code {
|
||||
@include type.text-5xl;
|
||||
margin: 0;
|
||||
color: var(--nds-accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@include type.text-3xl;
|
||||
margin: 0;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
|
||||
.body {
|
||||
@include type.text-base;
|
||||
margin: 0;
|
||||
max-width: 34rem;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import AboutView from '../views/AboutView.astro';
|
||||
---
|
||||
|
||||
<AboutView />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import ContactView from '../views/ContactView.astro';
|
||||
---
|
||||
|
||||
<ContactView />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import DocsView from '../views/DocsView.astro';
|
||||
---
|
||||
|
||||
<DocsView />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import AboutView from '../../views/AboutView.astro';
|
||||
---
|
||||
|
||||
<AboutView />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import ContactView from '../../views/ContactView.astro';
|
||||
---
|
||||
|
||||
<ContactView />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import DocsView from '../../views/DocsView.astro';
|
||||
---
|
||||
|
||||
<DocsView />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import HomeView from '../../views/HomeView.astro';
|
||||
---
|
||||
|
||||
<HomeView />
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import ProjectView from '../../../views/ProjectView.astro';
|
||||
import { projects } from '../../../data/projects';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return projects.map((project) => ({ params: { slug: project.slug }, props: { project } }));
|
||||
}
|
||||
|
||||
const { project } = Astro.props;
|
||||
---
|
||||
|
||||
<ProjectView project={project} />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import WorkIndexView from '../../../views/WorkIndexView.astro';
|
||||
---
|
||||
|
||||
<WorkIndexView />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import HomeView from '../views/HomeView.astro';
|
||||
---
|
||||
|
||||
<HomeView />
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
import ProjectView from '../../views/ProjectView.astro';
|
||||
import { projects } from '../../data/projects';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return projects.map((project) => ({ params: { slug: project.slug }, props: { project } }));
|
||||
}
|
||||
|
||||
const { project } = Astro.props;
|
||||
---
|
||||
|
||||
<ProjectView project={project} />
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
import WorkIndexView from '../../views/WorkIndexView.astro';
|
||||
---
|
||||
|
||||
<WorkIndexView />
|
||||
@@ -0,0 +1,3 @@
|
||||
// Re-exports NDS typography mixins (text-sm ... text-5xl) + font vars.
|
||||
// The tokens directory is exposed through a Sass loadPath (see astro.config.mjs).
|
||||
@forward 'typography';
|
||||
@@ -0,0 +1,73 @@
|
||||
@use './type' as type;
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
background: var(--nds-background);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
color: var(--nds-text);
|
||||
font-family: var(--nds-font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--nds-primary);
|
||||
color: var(--nds-on-primary);
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: var(--nds-font-mono);
|
||||
text-wrap: balance;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p { margin: 0; }
|
||||
|
||||
img, svg, canvas { max-width: 100%; height: auto; }
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--nds-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding-block: clamp(64px, 10vh, 128px);
|
||||
}
|
||||
|
||||
.eyebrow-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--nds-spacing-md);
|
||||
margin-bottom: var(--nds-spacing-lg);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@include type.text-4xl;
|
||||
font-size: clamp(1.6rem, 3.4vw, 2.4rem);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Lenis smooth scroll (recommended base styles) ---- */
|
||||
html.lenis, html.lenis body { height: auto; }
|
||||
.lenis.lenis-smooth { scroll-behavior: auto !important; }
|
||||
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
|
||||
.lenis.lenis-stopped { overflow: hidden; }
|
||||
|
||||
/* ---- Cinema mode (home voyage): the footer slides over the last overlay ---- */
|
||||
html.cinema footer {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
background: var(--nds-background);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import { Badge, Button } from '@unkn0wndo3s/nova-design-system';
|
||||
import { breadcrumbLd, profilePageLd } from '../lib/seo';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
import type { Localized } from '../data/projects';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
interface Step {
|
||||
period: Localized;
|
||||
role: Localized;
|
||||
place: string;
|
||||
detail: Localized;
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
period: { fr: 'Sept. 2025 - Août 2026', en: 'Sept. 2025 - Aug. 2026' },
|
||||
role: { fr: 'Développeur full-stack en alternance', en: 'Full-stack developer (apprenticeship)' },
|
||||
place: 'Legrand France, Limoges',
|
||||
detail: {
|
||||
fr: "Développement du DAN, application web interne pour les ateliers de production : refonte du module de gestion des utilisateurs (Vue.js/TypeScript, export CSV) et du module de statistiques avec composants graphiques sur mesure. Back-end Node.js/Express.",
|
||||
en: 'Development of DAN, an internal web app for production workshops: rework of the user management module (Vue.js/TypeScript, CSV export) and of the statistics module with custom chart components. Node.js/Express back-end.',
|
||||
},
|
||||
},
|
||||
{
|
||||
period: { fr: 'Avril - Août 2025', en: 'April - Aug. 2025' },
|
||||
role: { fr: "Stage puis job d'été", en: 'Internship, then summer job' },
|
||||
place: 'Legrand France, Limoges',
|
||||
detail: {
|
||||
fr: "Premiers travaux sur le DAN : montée en compétence sur la base de code, premières fonctionnalités livrées en production. L'expérience s'est prolongée en alternance sur la même application.",
|
||||
en: 'First work on DAN: ramping up on the codebase, first features shipped to production. The experience continued as an apprenticeship on the same application.',
|
||||
},
|
||||
},
|
||||
{
|
||||
period: { fr: '2023 - 2026', en: '2023 - 2026' },
|
||||
role: { fr: 'BUT MMI, parcours Développement Web (DWDI)', en: 'BUT MMI degree, Web Development track (DWDI)' },
|
||||
place: 'IUT du Limousin',
|
||||
detail: {
|
||||
fr: "Formation en développement web et dispositifs interactifs : front-end, back-end, UX, gestion de projet. Diplôme obtenu en 2026.",
|
||||
en: 'Training in web development and interactive systems: front-end, back-end, UX, project management. Graduating in 2026.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
interface Value {
|
||||
title: Localized;
|
||||
body: Localized;
|
||||
}
|
||||
|
||||
const values: Value[] = [
|
||||
{
|
||||
title: { fr: 'Livrer, pas prototyper', en: 'Ship, not prototype' },
|
||||
body: {
|
||||
fr: 'Un projet est terminé quand il tourne en production avec sa CI. Mon design system est sur npm, mon infra héberge ce site : tout ce que je montre est réellement en service.',
|
||||
en: 'A project is done when it runs in production with its CI. My design system is on npm, my infrastructure hosts this site: everything I show is actually in service.',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: { fr: 'Du composant au serveur', en: 'From component to server' },
|
||||
body: {
|
||||
fr: "Je conçois l'interface, l'API qui la sert et la machine qui l'héberge. Cette vision complète évite les frictions entre front, back et ops.",
|
||||
en: 'I design the interface, the API that serves it and the machine that hosts it. That end-to-end view removes friction between front, back and ops.',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: { fr: 'Réutilisable par défaut', en: 'Reusable by default' },
|
||||
body: {
|
||||
fr: "Tokens, composants, pipelines : je factorise ce qui se répète. C'est ce qui m'a mené à construire Nova Design System plutôt que de copier-coller des styles.",
|
||||
en: 'Tokens, components, pipelines: I factor out what repeats. That mindset is what led me to build Nova Design System instead of copy-pasting styles.',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const crumbs = [
|
||||
{ name: t('nav.home'), url: localizePath('/', locale) },
|
||||
{ name: t('nav.about'), url: localizePath('/about', locale) },
|
||||
];
|
||||
const pageUrl = new URL(Astro.url.pathname, Astro.site ?? 'https://louis-potevin.dev').href;
|
||||
const jsonLd = [breadcrumbLd(crumbs), profilePageLd(pageUrl)];
|
||||
---
|
||||
|
||||
<BaseLayout title={t('meta.about.title')} description={t('meta.about.description')} jsonLd={jsonLd}>
|
||||
<header class="page-head">
|
||||
<h1 class="page-title">{t('about.title')}</h1>
|
||||
<p class="page-lead">{t('about.lead')}</p>
|
||||
<p class="availability">
|
||||
<Badge type="success">{t('avail.badge')}</Badge>
|
||||
<span>{t('avail.line')} · {t('avail.where')}</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="section" aria-labelledby="exp-title">
|
||||
<div class="eyebrow-row"><Eyebrow index="01">{t('about.exp.eyebrow')}</Eyebrow></div>
|
||||
<h2 id="exp-title" class="section-title">{t('about.exp.title')}</h2>
|
||||
<ol class="timeline">
|
||||
{steps.map((step) => (
|
||||
<li>
|
||||
<p class="timeline__period">{step.period[locale]}</p>
|
||||
<h3>{step.role[locale]}</h3>
|
||||
<p class="timeline__place">{step.place}</p>
|
||||
<p class="timeline__detail">{step.detail[locale]}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="section" aria-labelledby="values-title">
|
||||
<div class="eyebrow-row"><Eyebrow index="02">{t('about.values.eyebrow')}</Eyebrow></div>
|
||||
<h2 id="values-title" class="section-title">{t('about.values.title')}</h2>
|
||||
<ul class="values">
|
||||
{values.map((value) => (
|
||||
<li>
|
||||
<h3>{value.title[locale]}</h3>
|
||||
<p>{value.body[locale]}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div class="actions">
|
||||
<Button href={localizePath('/contact', locale)} size="lg">{t('nav.cta')}</Button>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.page-head {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-md);
|
||||
padding-block: clamp(48px, 8vh, 96px) clamp(24px, 4vh, 48px);
|
||||
}
|
||||
|
||||
.page-title { @include type.text-5xl; font-size: clamp(2rem, 5.5vw, 3.4rem); }
|
||||
.page-lead {
|
||||
@include type.text-lg;
|
||||
font-weight: 400;
|
||||
max-width: 62ch;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.availability {
|
||||
@include type.text-label;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xl);
|
||||
margin: var(--nds-spacing-xl) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
padding-left: var(--nds-spacing-xl);
|
||||
border-left: var(--nds-border-width-medium) solid var(--nds-border);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -7px;
|
||||
top: 6px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--nds-radius-full);
|
||||
background: var(--nds-primary);
|
||||
}
|
||||
}
|
||||
|
||||
h3 { @include type.text-xl; margin-block: var(--nds-spacing-2xs); }
|
||||
}
|
||||
|
||||
.timeline__period {
|
||||
@include type.text-label;
|
||||
color: var(--nds-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.timeline__place { @include type.text-label; color: var(--nds-neutral); }
|
||||
.timeline__detail {
|
||||
@include type.text-base;
|
||||
font-size: 1rem;
|
||||
margin-top: var(--nds-spacing-xs);
|
||||
max-width: 64ch;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.values {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
|
||||
gap: var(--nds-spacing-lg);
|
||||
margin: var(--nds-spacing-xl) 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
padding: var(--nds-spacing-lg);
|
||||
background: color-mix(in srgb, var(--nds-surface) 88%, transparent);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
}
|
||||
h3 { @include type.text-xl; color: var(--nds-primary); margin-bottom: var(--nds-spacing-xs); }
|
||||
p { @include type.text-base; color: var(--nds-neutral); }
|
||||
}
|
||||
|
||||
.actions { margin-top: var(--nds-spacing-2xl); }
|
||||
</style>
|
||||
@@ -0,0 +1,350 @@
|
||||
---
|
||||
/**
|
||||
* Contact page - static-friendly by design.
|
||||
*
|
||||
* The "form" is a mailto composer: it pre-fills the visitor's mail client and
|
||||
* stores nothing server-side. To switch to a real backend later, point the
|
||||
* submit handler at a Formspree/own endpoint and keep the same fields.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import BrandLinkedin from '../components/BrandLinkedin.astro';
|
||||
import { Mail, Copy, Send } from '@lucide/astro';
|
||||
import { Github, Gitea, Npm } from 'simple-icons-astro';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
Button,
|
||||
TextField,
|
||||
Select,
|
||||
SelectOption,
|
||||
} from '@unkn0wndo3s/nova-design-system';
|
||||
import { site } from '../data/site';
|
||||
import { breadcrumbLd } from '../lib/seo';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const crumbs = [
|
||||
{ name: t('nav.home'), url: localizePath('/', locale) },
|
||||
{ name: t('nav.contact'), url: localizePath('/contact', locale) },
|
||||
];
|
||||
|
||||
const subjects = [
|
||||
{ value: 'cdi', label: t('contact.form.subject.cdi') },
|
||||
{ value: 'freelance', label: t('contact.form.subject.freelance') },
|
||||
{ value: 'other', label: t('contact.form.subject.other') },
|
||||
];
|
||||
|
||||
const elsewhere = [
|
||||
{ ...site.links.github, icon: 'github' },
|
||||
{ ...site.links.gitea, icon: 'gitea' },
|
||||
{ ...site.links.npm, icon: 'npm' },
|
||||
{ ...site.links.linkedin, icon: 'linkedin' },
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('meta.contact.title')}
|
||||
description={t('meta.contact.description')}
|
||||
jsonLd={[breadcrumbLd(crumbs)]}
|
||||
>
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem href={crumbs[0]!.url}>{crumbs[0]!.name}</BreadcrumbItem>
|
||||
<BreadcrumbItem current>{crumbs[1]!.name}</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
</nav>
|
||||
|
||||
<header class="page-head">
|
||||
<Eyebrow>{t('avail.badge')}</Eyebrow>
|
||||
<h1 class="page-title">{t('contact.title')}</h1>
|
||||
<p class="page-lead">{t('contact.lead')}</p>
|
||||
</header>
|
||||
|
||||
<div class="contact-grid">
|
||||
<section class="composer" aria-labelledby="composer-title">
|
||||
<h2 id="composer-title" class="visually-hidden">{t('contact.form.send')}</h2>
|
||||
|
||||
<div class="composer-fields" data-contact-form data-email={site.email}>
|
||||
<div class="composer-row">
|
||||
<TextField id="contact-name" label={t('contact.form.name')} type="text" placeholder="Jane Doe" />
|
||||
<TextField
|
||||
id="contact-email"
|
||||
label={t('contact.form.email')}
|
||||
type="email"
|
||||
placeholder="jane@company.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="composer-subject">
|
||||
<span class="field-label">{t('contact.form.subject')}</span>
|
||||
<Select default={subjects[0]!.label}>
|
||||
{subjects.map((s) => <SelectOption value={s.label}>{s.label}</SelectOption>)}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<TextField
|
||||
id="contact-message"
|
||||
label={t('contact.form.message')}
|
||||
type="textarea"
|
||||
max={1200}
|
||||
placeholder="..."
|
||||
/>
|
||||
|
||||
<div class="composer-actions">
|
||||
<Button type="primary" htmlType="button" data-contact-send>
|
||||
<Send size={16} />
|
||||
{t('contact.form.send')}
|
||||
</Button>
|
||||
<p class="composer-note">{t('contact.form.note')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="direct">
|
||||
<section class="direct-block" aria-labelledby="direct-title">
|
||||
<h2 id="direct-title" class="direct-heading">{t('contact.email.label')}</h2>
|
||||
<p class="direct-email">
|
||||
<Mail size={18} aria-hidden="true" />
|
||||
<span data-email-text>{site.email}</span>
|
||||
</p>
|
||||
<div class="direct-actions">
|
||||
<Button type="secondary" href={`mailto:${site.email}`}>{t('contact.email.write')}</Button>
|
||||
<Button
|
||||
type="ghost"
|
||||
htmlType="button"
|
||||
data-copy-email
|
||||
data-copied-label={t('contact.email.copied')}
|
||||
>
|
||||
<Copy size={16} />
|
||||
<span data-copy-label>{t('contact.email.copy')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="direct-block" aria-labelledby="elsewhere-title">
|
||||
<h2 id="elsewhere-title" class="direct-heading">{t('contact.elsewhere')}</h2>
|
||||
<ul class="elsewhere-list">
|
||||
{
|
||||
elsewhere.map((l) => (
|
||||
<li>
|
||||
<a href={l.url} target="_blank" rel="noopener noreferrer">
|
||||
{l.icon === 'github' && <Github size={18} aria-hidden="true" />}
|
||||
{l.icon === 'gitea' && <Gitea size={18} aria-hidden="true" />}
|
||||
{l.icon === 'npm' && <Npm size={18} aria-hidden="true" />}
|
||||
{l.icon === 'linkedin' && <BrandLinkedin size={18} />}
|
||||
<span>{l.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
/** Builds a mailto: URL from the composer fields and opens it. Zero data stored. */
|
||||
function initContact() {
|
||||
const root = document.querySelector<HTMLElement>('[data-contact-form]');
|
||||
if (!root) return;
|
||||
|
||||
const email = root.dataset.email ?? '';
|
||||
const send = root.querySelector<HTMLButtonElement>('[data-contact-send]');
|
||||
|
||||
send?.addEventListener('click', () => {
|
||||
const name = (document.getElementById('contact-name')?.querySelector('input') as HTMLInputElement | null)?.value?.trim() ?? '';
|
||||
const from = (document.getElementById('contact-email')?.querySelector('input') as HTMLInputElement | null)?.value?.trim() ?? '';
|
||||
const message = (document.getElementById('contact-message')?.querySelector('textarea') as HTMLTextAreaElement | null)?.value?.trim() ?? '';
|
||||
|
||||
const selected =
|
||||
root.querySelector<HTMLElement>('nds-select [aria-selected="true"]')?.dataset.value ??
|
||||
root.querySelector<HTMLElement>('nds-select')?.dataset.default ??
|
||||
'';
|
||||
|
||||
const subject = `[Portfolio] ${selected}${name ? ` - ${name}` : ''}`;
|
||||
const bodyParts = [message];
|
||||
if (name || from) bodyParts.push('', '--', [name, from].filter(Boolean).join(' · '));
|
||||
|
||||
const url = `mailto:${email}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(bodyParts.join('\n'))}`;
|
||||
window.location.href = url;
|
||||
});
|
||||
|
||||
// Copy-to-clipboard with temporary label swap.
|
||||
const copyBtn = document.querySelector<HTMLButtonElement>('[data-copy-email]');
|
||||
const copyLabel = copyBtn?.querySelector<HTMLElement>('[data-copy-label]');
|
||||
copyBtn?.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(email);
|
||||
if (copyLabel && copyBtn.dataset.copiedLabel) {
|
||||
const original = copyLabel.textContent;
|
||||
copyLabel.textContent = copyBtn.dataset.copiedLabel;
|
||||
window.setTimeout(() => {
|
||||
copyLabel.textContent = original;
|
||||
}, 1800);
|
||||
}
|
||||
} catch {
|
||||
/* Clipboard unavailable (permissions/http) - the address stays visible as text. */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initContact();
|
||||
document.addEventListener('astro:page-load', initContact);
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.crumbs {
|
||||
margin-block: var(--nds-spacing-lg) var(--nds-spacing-xl);
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-sm);
|
||||
margin-block-end: var(--nds-spacing-2xl);
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@include type.text-4xl;
|
||||
margin: 0;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
|
||||
.page-lead {
|
||||
@include type.text-lg;
|
||||
margin: 0;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.contact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
|
||||
gap: var(--nds-spacing-2xl);
|
||||
align-items: start;
|
||||
margin-block-end: var(--nds-spacing-3xl);
|
||||
|
||||
@media (max-width: 840px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.composer {
|
||||
background: var(--nds-surface);
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
padding: var(--nds-spacing-xl);
|
||||
box-shadow: var(--nds-shadow-sm);
|
||||
}
|
||||
|
||||
.composer-fields {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-lg);
|
||||
}
|
||||
|
||||
.composer-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--nds-spacing-lg);
|
||||
|
||||
@media (max-width: 560px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.composer-subject {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-sm);
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.composer-note {
|
||||
@include type.text-sm;
|
||||
margin: 0;
|
||||
color: var(--nds-disabled);
|
||||
}
|
||||
|
||||
.direct {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xl);
|
||||
}
|
||||
|
||||
.direct-block {
|
||||
background: var(--nds-surface);
|
||||
border: 1px solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
padding: var(--nds-spacing-xl);
|
||||
}
|
||||
|
||||
.direct-heading {
|
||||
@include type.text-lg;
|
||||
margin: 0 0 var(--nds-spacing-md);
|
||||
color: var(--nds-text);
|
||||
}
|
||||
|
||||
.direct-email {
|
||||
@include type.text-base;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-xs);
|
||||
margin: 0 0 var(--nds-spacing-md);
|
||||
color: var(--nds-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.direct-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-sm);
|
||||
}
|
||||
|
||||
.elsewhere-list {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
padding: var(--nds-spacing-sm) var(--nds-spacing-md);
|
||||
border-radius: var(--nds-radius-md);
|
||||
color: var(--nds-text);
|
||||
text-decoration: none;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--nds-surface-hover);
|
||||
color: var(--nds-accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,519 @@
|
||||
---
|
||||
/**
|
||||
* Home = the voyage. One long timeline: hero → profile → asteroid belt with a
|
||||
* beacon per project → stack constellation → contact portal.
|
||||
*
|
||||
* Two rendering modes from the same markup:
|
||||
* - default (no JS, no WebGL, reduced motion): normal stacked sections
|
||||
* - `html.cinema` (set by voyage.ts): sections become fixed overlays,
|
||||
* the wrapper's height becomes the scroll timeline.
|
||||
*/
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Eyebrow from '../components/Eyebrow.astro';
|
||||
import TechIcon from '../components/TechIcon.astro';
|
||||
import { Copy } from '@lucide/astro';
|
||||
import { Badge, Button } from '@unkn0wndo3s/nova-design-system';
|
||||
import { site, stack } from '../data/site';
|
||||
import { featuredProjects, projects, type Project } from '../data/projects';
|
||||
import { breadcrumbLd, profilePageLd } from '../lib/seo';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const voyageProjects: Project[] = [...featuredProjects, ...projects.filter((p) => !p.featured)];
|
||||
const statusType = { live: 'success', maintained: 'info', wip: 'warning' } as const;
|
||||
const statusLabel = {
|
||||
live: t('work.status.live'),
|
||||
maintained: t('work.status.maintained'),
|
||||
wip: t('work.status.wip'),
|
||||
} as const;
|
||||
|
||||
const pillars = [
|
||||
{ title: t('home.profile.front.title'), body: t('home.profile.front.body') },
|
||||
{ title: t('home.profile.back.title'), body: t('home.profile.back.body') },
|
||||
{ title: t('home.profile.ops.title'), body: t('home.profile.ops.body') },
|
||||
];
|
||||
|
||||
const dots = ['hero', 'about', 'projects', ...voyageProjects.map((p) => `p-${p.slug}`), 'stack', 'contact'];
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('meta.home.title')}
|
||||
description={t('meta.home.description')}
|
||||
jsonLd={[breadcrumbLd([{ name: t('nav.home'), url: localizePath('/', locale) }]), profilePageLd(new URL(Astro.url.pathname, Astro.site ?? 'https://louis-potevin.dev').href)]}
|
||||
background={false}
|
||||
fullBleed
|
||||
>
|
||||
<canvas class="voyage-canvas" data-voyage-canvas aria-hidden="true"></canvas>
|
||||
|
||||
<div class="voyage" data-voyage>
|
||||
<!-- 01 · Hero -->
|
||||
<section class="seg seg--center" data-seg="hero" data-weight="1.1" id="hero">
|
||||
<div class="card card--hero">
|
||||
<p class="hero-avail"><Badge type="success" variant="soft">{t('avail.badge')}</Badge> <span>{t('avail.line')}</span></p>
|
||||
<h1 class="hero-title">
|
||||
<span class="hero-name">{site.name}</span><span class="sr-only">, </span>
|
||||
<span class="hero-role">{t('home.hero.title.role')}</span>
|
||||
</h1>
|
||||
<p class="hero-lead">{t('home.hero.lead')}</p>
|
||||
<div class="row">
|
||||
<Button type="primary" href={localizePath('/#projects', locale)}>{t('home.hero.viewProjects')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 02 · Profil -->
|
||||
<section class="seg seg--left" data-seg="about" data-weight="1.3" data-side="left" id="about">
|
||||
<div class="card">
|
||||
<Eyebrow>{t('home.profile.eyebrow')}</Eyebrow>
|
||||
<h2 class="card-title">{t('home.profile.title')}</h2>
|
||||
<p class="card-body">{t('home.profile.body1')}</p>
|
||||
<p class="card-body card-body--muted">{t('home.about.summary')}</p>
|
||||
<ul class="pillars">
|
||||
{pillars.map((p) => (
|
||||
<li><strong>{p.title}</strong><span>{p.body}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 03 · Cap sur les projets -->
|
||||
<section class="seg seg--center" data-seg="projects" data-weight="0.7" id="projects">
|
||||
<div class="card card--center">
|
||||
<Eyebrow>{t('home.projects.eyebrow')}</Eyebrow>
|
||||
<h2 class="card-title card-title--big">{t('home.projects.intro.title')}</h2>
|
||||
<p class="card-body">{t('home.projects.intro.body')}</p>
|
||||
<div class="row row--center">
|
||||
<Button type="secondary" href={localizePath('/work', locale)}>{t('home.projects.all')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 04-07 · Un segment par projet, cartes alternées -->
|
||||
{voyageProjects.map((p, i) => (
|
||||
<div
|
||||
class={`seg seg--${i % 2 ? 'left' : 'right'}`}
|
||||
data-seg={`p-${p.slug}`}
|
||||
data-weight="1.15"
|
||||
data-side={i % 2 ? 'left' : 'right'}
|
||||
data-project
|
||||
id={`p-${p.slug}`}
|
||||
>
|
||||
<article class="card card--project">
|
||||
<p class="proj-index" aria-hidden="true">{String(i + 1).padStart(2, '0')}</p>
|
||||
<div class="proj-head">
|
||||
<Badge type={statusType[p.status]}>{statusLabel[p.status]}</Badge>
|
||||
</div>
|
||||
<h2 class="card-title">{p.name}</h2>
|
||||
<p class="card-body">{p.tagline[locale]}</p>
|
||||
<ul class="proj-highlights">
|
||||
{p.highlights.slice(0, 2).map((h) => <li>{h[locale]}</li>)}
|
||||
</ul>
|
||||
<div class="row">
|
||||
<Button type="primary" href={localizePath(`/work/${p.slug}`, locale)}>
|
||||
{t('home.projects.view')}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<!-- 08 · Stack -->
|
||||
<section class="seg seg--center" data-seg="stack" data-weight="1.05" id="stack">
|
||||
<div class="card card--center card--wide">
|
||||
<Eyebrow>{t('home.stack.eyebrow')}</Eyebrow>
|
||||
<h2 class="card-title">{t('home.stack.title')}</h2>
|
||||
<ul class="stack-grid">
|
||||
{stack.map((s) => <li><TechIcon name={s.name} icon={s.icon} /></li>)}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 09 · Contact (arrivée au portail) -->
|
||||
<section class="seg seg--center" data-seg="contact" data-weight="1.2" id="contact">
|
||||
<div class="card card--center">
|
||||
<Eyebrow>{t('contact.title')}</Eyebrow>
|
||||
<h2 class="card-title card-title--big">{t('home.cta.title')}</h2>
|
||||
<p class="card-body">{t('home.cta.body')}</p>
|
||||
<div class="row row--center">
|
||||
<Button type="primary" href={`mailto:${site.email}`}>{t('contact.email.write')}</Button>
|
||||
<Button type="ghost" htmlType="button" data-copy-email data-copied-label={t('contact.email.copied')}>
|
||||
<Copy size={16} />
|
||||
<span data-copy-label>{t('contact.email.copy')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<p class="contact-email" data-email-text>{site.email}</p>
|
||||
<p class="contact-where">{t('avail.where')}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Timeline UI (cinema mode only) -->
|
||||
<div class="rail" aria-hidden="true">
|
||||
<div class="rail__bar" data-voyage-progress></div>
|
||||
</div>
|
||||
<nav class="dots" aria-label={t('voyage.progress')}>
|
||||
{dots.map((id) => <button type="button" data-voyage-dot={id} aria-label={id}></button>)}
|
||||
</nav>
|
||||
<script>
|
||||
import { ensureSmooth } from '../lib/smooth';
|
||||
import type { VoyageHandle } from '../components/scene/voyage';
|
||||
|
||||
let handle: VoyageHandle | null = null;
|
||||
|
||||
async function init() {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('[data-voyage-canvas]');
|
||||
const container = document.querySelector<HTMLElement>('[data-voyage]');
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const lenis = ensureSmooth();
|
||||
if (!lenis) return; // reduced motion → static page, canvas stays hidden
|
||||
|
||||
// Three.js only downloads when the cinema mode is actually possible.
|
||||
const { mountVoyage } = await import('../components/scene/voyage');
|
||||
handle = mountVoyage({ canvas, container, lenis });
|
||||
if (!handle) return; // no WebGL → static page
|
||||
|
||||
// Deep links (/#about, nav anchors...) become smooth seeks on the timeline.
|
||||
if (location.hash) handle.seek(location.hash.slice(1), true);
|
||||
document.addEventListener('click', (e) => {
|
||||
const a = (e.target as HTMLElement).closest<HTMLAnchorElement>('a[href*="#"]');
|
||||
if (!a || !handle) return;
|
||||
if (a.pathname !== location.pathname) return;
|
||||
const id = a.hash.slice(1);
|
||||
e.preventDefault();
|
||||
history.replaceState(null, '', a.hash);
|
||||
handle.seek(id);
|
||||
});
|
||||
}
|
||||
|
||||
function initCopy() {
|
||||
const btn = document.querySelector<HTMLButtonElement>('[data-copy-email]');
|
||||
const label = btn?.querySelector<HTMLElement>('[data-copy-label]');
|
||||
const email = document.querySelector<HTMLElement>('[data-email-text]')?.textContent?.trim() ?? '';
|
||||
btn?.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(email);
|
||||
if (label && btn.dataset.copiedLabel) {
|
||||
const orig = label.textContent;
|
||||
label.textContent = btn.dataset.copiedLabel;
|
||||
window.setTimeout(() => (label.textContent = orig), 1800);
|
||||
}
|
||||
} catch {
|
||||
/* clipboard unavailable - the address stays readable as text */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
initCopy();
|
||||
</script>
|
||||
</BaseLayout>
|
||||
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
/* ---------- Canvas & timeline chrome (hidden until cinema mode) --------- */
|
||||
.voyage-canvas {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
}
|
||||
.rail,
|
||||
.dots {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(html.cinema) .voyage-canvas {
|
||||
display: block;
|
||||
}
|
||||
:global(html.cinema) .rail {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
top: 0;
|
||||
height: 2px;
|
||||
z-index: 60;
|
||||
background: color-mix(in srgb, var(--nds-border) 60%, transparent);
|
||||
|
||||
.rail__bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--nds-primary), var(--nds-accent));
|
||||
transform-origin: left;
|
||||
transform: scaleX(0);
|
||||
}
|
||||
}
|
||||
:global(html.cinema) .dots {
|
||||
position: fixed;
|
||||
right: clamp(10px, 2vw, 26px);
|
||||
top: 50%;
|
||||
translate: 0 -50%;
|
||||
z-index: 60;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
/* 22px hit area (touch target), 8px painted dot */
|
||||
button {
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
translate: -50% -50%;
|
||||
border: 1px solid var(--nds-neutral);
|
||||
border-radius: var(--nds-radius-full);
|
||||
transition: background-color 0.25s ease, transform 0.25s ease, border-color 0.25s ease;
|
||||
}
|
||||
&.is-active::before {
|
||||
background: var(--nds-accent);
|
||||
border-color: var(--nds-accent);
|
||||
transform: scale(1.35);
|
||||
}
|
||||
&:hover::before {
|
||||
border-color: var(--nds-text);
|
||||
}
|
||||
}
|
||||
@media (max-width: 840px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------- Sections: static default --------------------- */
|
||||
.seg {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-inline: clamp(20px, 6vw, 72px);
|
||||
|
||||
&.seg--center {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
&.seg--right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cinema: fixed overlays, driven per-frame by voyage.ts */
|
||||
:global(html.cinema) .seg {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
min-height: 0;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
will-change: opacity, transform;
|
||||
|
||||
&.is-active {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------- Cards ---------------------------------- */
|
||||
.card {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-md);
|
||||
justify-items: start;
|
||||
width: min(600px, 94vw);
|
||||
padding: clamp(var(--nds-spacing-lg), 3vw, var(--nds-spacing-2xl));
|
||||
background: color-mix(in srgb, var(--nds-background) 62%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--nds-border) 80%, transparent);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: var(--nds-shadow-md);
|
||||
}
|
||||
.card--center {
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.card--wide {
|
||||
width: min(780px, 94vw);
|
||||
}
|
||||
.card--hero {
|
||||
width: min(860px, 94vw);
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-sm);
|
||||
}
|
||||
.row--center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@include type.text-3xl;
|
||||
margin: 0;
|
||||
color: var(--nds-text);
|
||||
text-wrap: balance;
|
||||
}
|
||||
.card-title--big {
|
||||
@include type.text-4xl;
|
||||
}
|
||||
.card-body {
|
||||
@include type.text-base;
|
||||
margin: 0;
|
||||
max-width: 56ch;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
.card-body--muted {
|
||||
color: var(--nds-disabled);
|
||||
}
|
||||
|
||||
/* Hero */
|
||||
.hero-avail {
|
||||
@include type.text-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
margin: 0;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
.hero-title {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
margin: 0;
|
||||
}
|
||||
.hero-name {
|
||||
@include type.text-5xl;
|
||||
color: var(--nds-text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.hero-role {
|
||||
@include type.text-xl;
|
||||
color: var(--nds-accent);
|
||||
}
|
||||
.hero-lead {
|
||||
@include type.text-lg;
|
||||
margin: 0;
|
||||
max-width: 58ch;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
/* Profil */
|
||||
.pillars {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-sm);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding-left: var(--nds-spacing-md);
|
||||
border-left: 2px solid var(--nds-primary);
|
||||
|
||||
strong {
|
||||
@include type.text-label;
|
||||
color: var(--nds-text);
|
||||
}
|
||||
span {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Projets */
|
||||
.card--project {
|
||||
position: relative;
|
||||
}
|
||||
.proj-index {
|
||||
@include type.text-5xl;
|
||||
position: absolute;
|
||||
top: -0.55em;
|
||||
right: var(--nds-spacing-lg);
|
||||
margin: 0;
|
||||
color: color-mix(in srgb, var(--nds-accent) 26%, transparent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
.proj-highlights {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-2xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
@include type.text-sm;
|
||||
color: var(--nds-neutral);
|
||||
padding-left: var(--nds-spacing-md);
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '◆';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--nds-accent);
|
||||
font-size: 0.6em;
|
||||
top: 0.45em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Stack */
|
||||
.stack-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--nds-spacing-sm);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Contact */
|
||||
.contact-email {
|
||||
@include type.text-base;
|
||||
margin: 0;
|
||||
color: var(--nds-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
.contact-where {
|
||||
@include type.text-sm;
|
||||
margin: 0;
|
||||
color: var(--nds-disabled);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.seg,
|
||||
.seg.seg--right {
|
||||
justify-content: center;
|
||||
}
|
||||
.proj-index {
|
||||
right: var(--nds-spacing-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import TechIcon from '../components/TechIcon.astro';
|
||||
import ComponentShowcase from '../components/showcase/ComponentShowcase.astro';
|
||||
import InfraScene from '../components/scenes/InfraScene.astro';
|
||||
import BlueprintScene from '../components/scenes/BlueprintScene.astro';
|
||||
import SorterScene from '../components/scenes/SorterScene.astro';
|
||||
import { Badge, Button, Breadcrumb, BreadcrumbItem, Link } from '@unkn0wndo3s/nova-design-system';
|
||||
import { projects, type Project } from '../data/projects';
|
||||
import { breadcrumbLd, projectLd } from '../lib/seo';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
|
||||
export interface Props {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
const { project } = Astro.props;
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const statusType = { live: 'success', maintained: 'info', wip: 'warning' } as const;
|
||||
const statusLabel = {
|
||||
live: t('work.status.live'),
|
||||
maintained: t('work.status.maintained'),
|
||||
wip: t('work.status.wip'),
|
||||
} as const;
|
||||
|
||||
const crumbs = [
|
||||
{ name: t('nav.home'), url: localizePath('/', locale) },
|
||||
{ name: t('nav.work'), url: localizePath('/work', locale) },
|
||||
{ name: project.name, url: localizePath(`/work/${project.slug}`, locale) },
|
||||
];
|
||||
|
||||
const pageUrl = new URL(Astro.url.pathname, Astro.site ?? 'https://louis-potevin.dev').href;
|
||||
const jsonLd = [breadcrumbLd(crumbs), projectLd(project, locale, pageUrl)];
|
||||
|
||||
const title = `${project.name} - ${t('meta.work.title')}`;
|
||||
const next = projects[(projects.findIndex((p) => p.slug === project.slug) + 1) % projects.length]!;
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={project.summary[locale]} jsonLd={jsonLd} ogType="article">
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem href={crumbs[0]!.url}>{crumbs[0]!.name}</BreadcrumbItem>
|
||||
<BreadcrumbItem href={crumbs[1]!.url}>{crumbs[1]!.name}</BreadcrumbItem>
|
||||
<BreadcrumbItem current>{project.name}</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
</nav>
|
||||
|
||||
<article>
|
||||
<header class="head">
|
||||
<Badge type={statusType[project.status]}>{statusLabel[project.status]}</Badge>
|
||||
<h1 class="head__title">{project.name}</h1>
|
||||
<p class="head__tagline">{project.tagline[locale]}</p>
|
||||
</header>
|
||||
|
||||
{project.slug === 'nova-infra' && <InfraScene />}
|
||||
{project.slug === 'portfolio' && <BlueprintScene />}
|
||||
{project.slug === 'file-organizer' && <SorterScene />}
|
||||
|
||||
<div class="layout">
|
||||
<div class="body">
|
||||
{project.body.map((paragraph) => <p>{paragraph[locale]}</p>)}
|
||||
|
||||
<h2>{t('work.highlights')}</h2>
|
||||
<ul class="highlights">
|
||||
{project.highlights.map((h) => <li>{h[locale]}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<aside class="meta" aria-label={t('work.stack')}>
|
||||
<h2>{t('work.stack')}</h2>
|
||||
<ul class="meta__stack">
|
||||
{project.stack.map((tech) => (
|
||||
<li><TechIcon name={tech.name} icon={tech.icon} size={16} /></li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{project.links.length > 0 && (
|
||||
<>
|
||||
<h2>{t('work.links')}</h2>
|
||||
<ul class="meta__links">
|
||||
{project.links.map((link) => (
|
||||
<li><Link url={link.url} blank>{link.label}</Link></li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{project.slug === 'nova-design-system' && <ComponentShowcase />}
|
||||
|
||||
<footer class="next">
|
||||
<Button href={localizePath('/work', locale)} type="ghost">{t('work.backToList')}</Button>
|
||||
<Button href={localizePath(`/work/${next.slug}`, locale)} type="secondary">
|
||||
{t('work.next')} : {next.name}
|
||||
</Button>
|
||||
</footer>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.crumbs { padding-top: var(--nds-spacing-lg); }
|
||||
|
||||
.head {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: var(--nds-spacing-md);
|
||||
padding-block: clamp(32px, 6vh, 64px) clamp(24px, 4vh, 48px);
|
||||
}
|
||||
|
||||
.head__title {
|
||||
@include type.text-5xl;
|
||||
font-size: clamp(2rem, 5.5vw, 3.4rem);
|
||||
}
|
||||
|
||||
.head__tagline {
|
||||
@include type.text-lg;
|
||||
font-weight: 400;
|
||||
color: var(--nds-primary);
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: clamp(24px, 5vw, 64px);
|
||||
align-items: start;
|
||||
|
||||
@media (max-width: 840px) { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-md);
|
||||
|
||||
p {
|
||||
@include type.text-base;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
color: var(--nds-neutral);
|
||||
max-width: 68ch;
|
||||
}
|
||||
h2 {
|
||||
@include type.text-2xl;
|
||||
margin-top: var(--nds-spacing-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.highlights {
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-xs);
|
||||
margin: 0;
|
||||
padding-left: 1.2em;
|
||||
|
||||
li {
|
||||
@include type.text-base;
|
||||
font-size: 1rem;
|
||||
color: var(--nds-neutral);
|
||||
&::marker { color: var(--nds-primary); }
|
||||
}
|
||||
}
|
||||
|
||||
.meta {
|
||||
position: sticky;
|
||||
top: 88px;
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-md);
|
||||
padding: var(--nds-spacing-lg);
|
||||
background: color-mix(in srgb, var(--nds-surface) 88%, transparent);
|
||||
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||
border-radius: var(--nds-radius-lg);
|
||||
|
||||
h2 {
|
||||
@include type.text-label;
|
||||
color: var(--nds-neutral);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
@media (max-width: 840px) { position: static; }
|
||||
}
|
||||
|
||||
.meta__stack,
|
||||
.meta__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--nds-spacing-xs);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.next {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: var(--nds-spacing-md);
|
||||
padding-block: clamp(40px, 7vh, 80px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import ProjectCard from '../components/ProjectCard.astro';
|
||||
import { Breadcrumb, BreadcrumbItem } from '@unkn0wndo3s/nova-design-system';
|
||||
import { projects } from '../data/projects';
|
||||
import { breadcrumbLd } from '../lib/seo';
|
||||
import { getLocaleFromUrl, useTranslations, localizePath } from '../i18n';
|
||||
|
||||
const locale = getLocaleFromUrl(Astro.url);
|
||||
const t = useTranslations(locale);
|
||||
|
||||
const crumbs = [
|
||||
{ name: t('nav.home'), url: localizePath('/', locale) },
|
||||
{ name: t('nav.work'), url: localizePath('/work', locale) },
|
||||
];
|
||||
---
|
||||
|
||||
<BaseLayout title={t('meta.work.title')} description={t('meta.work.description')} jsonLd={[breadcrumbLd(crumbs)]}>
|
||||
<nav class="crumbs" aria-label="Breadcrumb">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem href={crumbs[0]!.url}>{crumbs[0]!.name}</BreadcrumbItem>
|
||||
<BreadcrumbItem current>{crumbs[1]!.name}</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
</nav>
|
||||
|
||||
<header class="page-head">
|
||||
<h1 class="page-title">{t('work.title')}</h1>
|
||||
<p class="page-lead">{t('work.lead')}</p>
|
||||
</header>
|
||||
|
||||
<div class="grid" data-belt-anchor>
|
||||
{projects.map((project, i) => (
|
||||
<ProjectCard project={project} index={i} headingLevel="h2" />
|
||||
))}
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../styles/type' as type;
|
||||
|
||||
.crumbs { padding-top: var(--nds-spacing-lg); }
|
||||
|
||||
.page-head {
|
||||
padding-block: clamp(32px, 6vh, 64px);
|
||||
display: grid;
|
||||
gap: var(--nds-spacing-md);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@include type.text-5xl;
|
||||
font-size: clamp(2rem, 5.5vw, 3.4rem);
|
||||
}
|
||||
|
||||
.page-lead {
|
||||
@include type.text-lg;
|
||||
font-weight: 400;
|
||||
max-width: 60ch;
|
||||
color: var(--nds-neutral);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr));
|
||||
gap: var(--nds-spacing-lg);
|
||||
padding-bottom: var(--nds-spacing-2xl);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", ".astro/types.d.ts"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user