Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa37f0358d | |||
| 15bc86b5a6 |
+14
-135
@@ -1,138 +1,17 @@
|
|||||||
# ---> Node
|
# build
|
||||||
# Logs
|
dist/
|
||||||
logs
|
.astro/
|
||||||
|
|
||||||
|
# deps
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# env & logs
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
*.log
|
*.log
|
||||||
npm-debug.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.*
|
|
||||||
|
|
||||||
|
# os / editor
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|||||||
@@ -1,2 +1,106 @@
|
|||||||
# portfolio
|
# louis-potevin.dev
|
||||||
|
|
||||||
|
Professional portfolio of **Louis Potevin** — full-stack developer.
|
||||||
|
Static multi-page website built with **Astro + TypeScript + Sass**, styled
|
||||||
|
**exclusively** by the in-house design system **`@unkn0wndo3s/nova-design-system`**
|
||||||
|
(tokens, components), featuring a 3D space background controlled via scrolling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # dev server → http://localhost:4321
|
||||||
|
npm run build # static build → ./dist
|
||||||
|
npm run preview # preview the build
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Installation Note.** `@lucide/astro` has not yet declared its
|
||||||
|
compatibility with Astro 7 in its `peerDependencies`. The **`.npmrc`**
|
||||||
|
> file therefore enables `legacy-peer-deps=true` so that `npm install` succeeds.
|
||||||
|
> Functionality is not affected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edit Before Going Live
|
||||||
|
|
||||||
|
All modifiable content is centralized. Values to be confirmed are
|
||||||
|
marked with `TODO` in the code :
|
||||||
|
|
||||||
|
- **`src/data/site.ts`** — name, role, email, external links (Gitea, GitHub,
|
||||||
|
npm, LinkedIn). In particular, verify :
|
||||||
|
- **`src/data/projects.ts`** — la liste des projets (les « stations »).
|
||||||
|
Chaque entrée : résumé, contexte, contributions, stack, liens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├─ data/
|
||||||
|
│ ├─ site.ts # profile + links (single source of truth)
|
||||||
|
│ └─ projects.ts # project content
|
||||||
|
├─ layouts/
|
||||||
|
│ └─ BaseLayout.astro # NDS styles + 3D background + nav + footer + HUD
|
||||||
|
├─ components/
|
||||||
|
│ ├─ scene/scene.ts # Three.js background (stars, nebula, ambient asteroid)
|
||||||
|
│ ├─ SpaceBackground.astro
|
||||||
|
│ ├─ ProjectCard.astro # project card (NDS Card component)
|
||||||
|
│ ├─ SiteNav / SiteFooter / Eyebrow / TechIcon / BrandLinkedin
|
||||||
|
└─ pages/
|
||||||
|
├─ index.astro # home
|
||||||
|
├─ work/index.astro # all projects
|
||||||
|
├─ work/[slug].astro # project details (getStaticPaths)
|
||||||
|
├─ about.astro
|
||||||
|
└─ contact.astro
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design system
|
||||||
|
|
||||||
|
Components come from `@unkn0wndo3s/nova-design-system` :
|
||||||
|
`import { Button, Card, Badge, Navbar, Breadcrumb, TextField, Select, ... }`.
|
||||||
|
Global styles and tokens : `import '@unkn0wndo3s/nova-design-system/styles'`
|
||||||
|
(done once in `BaseLayout.astro`).
|
||||||
|
|
||||||
|
The package ships its raw `.astro`/`.scss` source files - Astro compiles them
|
||||||
|
on the consumer side. Since its `exports` field does not publish token partials
|
||||||
|
individually, the typography partial is exposed via a Sass
|
||||||
|
`loadPath` (see `astro.config.mjs`) and then re-exported by
|
||||||
|
`src/styles/_type.scss` (mixins `text-sm` … `text-5xl`).
|
||||||
|
|
||||||
|
Icons : `@lucide/astro` (UI) and `simple-icons-astro` (tech). Since LinkedIn is
|
||||||
|
no longer distributed by either of them (trademark policy), its glyph is provided
|
||||||
|
locally in `BrandLinkedin.astro`.
|
||||||
|
|
||||||
|
## 3D Background
|
||||||
|
|
||||||
|
An ambient asteroid slowly rotates and floats in the background, in the middle of a
|
||||||
|
starfield and a discrete nebula (Three.js). It does not follow scroll actions
|
||||||
|
and produces no engine flame effects: it is a backdrop, not the subject.
|
||||||
|
|
||||||
|
- **Without WebGL** : the canvas hides cleanly, the site remains readable.
|
||||||
|
|
||||||
|
## SEO
|
||||||
|
|
||||||
|
Meticulous search engine optimization, pre-configured:
|
||||||
|
|
||||||
|
- Per-page metadata (title, description, keywords), canonical URLs;
|
||||||
|
- Open Graph + Twitter Card with `public/og.png` (1200×630) ;
|
||||||
|
- Structured data JSON-LD (`Person` + `WebSite`) ;
|
||||||
|
- `sitemap-index.xml` (via `@astrojs/sitemap`) + `public/robots.txt`.
|
||||||
|
|
||||||
|
Remember to update `og.png` if you change your job title, and confirm the
|
||||||
|
links in `site.ts` (used in the `sameAs` property of the structured data).
|
||||||
|
|
||||||
|
## Contact Form
|
||||||
|
|
||||||
|
The site is 100% static: the form in `contact.astro` does not have a
|
||||||
|
backend. The **Send** button opens the user's mail client with a
|
||||||
|
pre-filled message (`mailto:`). For a server-side submission, connect a service
|
||||||
|
(Formspree, Resend, edge function…) to the `#cf-send` button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Built with Astro + Nova Design System.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
import sitemap from '@astrojs/sitemap';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
site: 'https://louis-potevin.dev',
|
||||||
|
trailingSlash: 'ignore',
|
||||||
|
integrations: [sitemap()],
|
||||||
|
vite: {
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
quietDeps: true,
|
||||||
|
loadPaths: [
|
||||||
|
'node_modules',
|
||||||
|
'node_modules/@unkn0wndo3s/nova-design-system/src/styles',
|
||||||
|
'src',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+4784
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "louis-potevin-dev",
|
||||||
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Portfolio Louis Potevin — louis-potevin.dev. Astro + Nova Design System.",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@astrojs/sitemap": "^3.7.3",
|
||||||
|
"@lucide/astro": "^1.21.0",
|
||||||
|
"@unkn0wndo3s/nova-design-system": "^1.2.1",
|
||||||
|
"astro": "^7.0.2",
|
||||||
|
"simple-icons-astro": "^16.1.0",
|
||||||
|
"three": "^0.184.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/three": "^0.184.0",
|
||||||
|
"sass": "^1.101.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#040B0B"/>
|
||||||
|
<!-- traînée de feu -->
|
||||||
|
<path d="M7 25 L13 19 L17 23 L11 29 Z" fill="#E0A23A" opacity="0.9"/>
|
||||||
|
<path d="M9 27 L14 22 L16 24 L11 29 Z" fill="#E05A59" opacity="0.85"/>
|
||||||
|
<!-- astéroïde -->
|
||||||
|
<circle cx="20" cy="12" r="6" fill="#2A4040"/>
|
||||||
|
<circle cx="20" cy="12" r="6" fill="none" stroke="#34C2C9" stroke-width="1.2"/>
|
||||||
|
<circle cx="18" cy="10.5" r="1.1" fill="#040B0B"/>
|
||||||
|
<circle cx="22" cy="13.5" r="0.9" fill="#040B0B"/>
|
||||||
|
<circle cx="21" cy="9.5" r="0.7" fill="#040B0B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 608 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,23 @@
|
|||||||
|
---
|
||||||
|
// LinkedIn is no longer distributed by @lucide/astro or simple-icons-astro
|
||||||
|
// (trademark policy). Official glyph reproduced inline, monochrome
|
||||||
|
// via `currentColor` to remain consistent with the other icons.
|
||||||
|
export interface Props {
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const { size = 18 } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
role="img"
|
||||||
|
aria-label="LinkedIn"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.85 0-2.14 1.45-2.14 2.94v5.67H9.34V9h3.42v1.56h.05c.48-.9 1.64-1.85 3.37-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.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.56V9h3.56v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.73v20.54C0 23.22.79 24 1.77 24h20.45c.98 0 1.78-.78 1.78-1.73V1.73C24 .77 23.2 0 22.22 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
export interface Props {
|
||||||
|
index?: string; // ex. "01"
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
const { index, label } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<p class="eyebrow">
|
||||||
|
{index && <span class="eyebrow__idx">{index}</span>}
|
||||||
|
<span class="eyebrow__line" aria-hidden="true"></span>
|
||||||
|
<span class="eyebrow__label">{label}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use 'styles/type' as *;
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-sm);
|
||||||
|
margin: 0 0 var(--nds-spacing-md);
|
||||||
|
}
|
||||||
|
.eyebrow__idx {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nds-accent);
|
||||||
|
}
|
||||||
|
.eyebrow__line {
|
||||||
|
width: 28px;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--nds-border-strong);
|
||||||
|
}
|
||||||
|
.eyebrow__label {
|
||||||
|
@include text-eyebrow;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
import { Card, Badge, Link } from '@unkn0wndo3s/nova-design-system';
|
||||||
|
import { ArrowUpRight } from '@lucide/astro';
|
||||||
|
import TechIcon from './TechIcon.astro';
|
||||||
|
import { statusMeta, type Project } from '../data/projects';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
const { project } = Astro.props;
|
||||||
|
const status = statusMeta[project.status];
|
||||||
|
const href = `/work/${project.slug}`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<article class="proj">
|
||||||
|
<Card title={project.title} subtitle={project.tagline}>
|
||||||
|
<div class="proj__meta">
|
||||||
|
<span class="proj__code">{project.designation}</span>
|
||||||
|
<Badge type={status.tone} variant="soft">{status.label}</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="proj__summary">{project.summary}</p>
|
||||||
|
<div class="proj__stack" aria-label="Stack technique">
|
||||||
|
{
|
||||||
|
project.stack.map((s) => (
|
||||||
|
<span class="proj__chip" title={s.label}>
|
||||||
|
<TechIcon name={s.icon} size={15} />
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div slot="footer" class="proj__footer">
|
||||||
|
<Link url={href}>
|
||||||
|
Voir le projet
|
||||||
|
<ArrowUpRight size={15} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use 'styles/type' as *;
|
||||||
|
|
||||||
|
.proj :global(.card) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: color-mix(in srgb, var(--nds-surface) 78%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
border-color: color-mix(in srgb, var(--nds-border) 90%, transparent);
|
||||||
|
transition:
|
||||||
|
transform 200ms ease,
|
||||||
|
border-color 200ms ease,
|
||||||
|
box-shadow 200ms ease;
|
||||||
|
}
|
||||||
|
.proj :global(.card:hover) {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
border-color: color-mix(in srgb, var(--nds-primary) 45%, var(--nds-border));
|
||||||
|
box-shadow: var(--nds-shadow-lg);
|
||||||
|
}
|
||||||
|
.proj :global(.card__body) {
|
||||||
|
gap: var(--nds-spacing-sm);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proj__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-sm);
|
||||||
|
}
|
||||||
|
.proj__code {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--nds-accent);
|
||||||
|
}
|
||||||
|
.proj__summary {
|
||||||
|
@include text-base;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.proj__stack {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--nds-spacing-2xs);
|
||||||
|
margin-top: var(--nds-spacing-2xs);
|
||||||
|
}
|
||||||
|
.proj__chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-2xs);
|
||||||
|
padding: 3px var(--nds-spacing-xs);
|
||||||
|
border-radius: var(--nds-radius-full);
|
||||||
|
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||||
|
background: var(--nds-surface-2);
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
.proj__footer {
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
---
|
||||||
|
import { Gitea, Github, Npm } from 'simple-icons-astro';
|
||||||
|
import { Mail } from '@lucide/astro';
|
||||||
|
import BrandLinkedin from './BrandLinkedin.astro';
|
||||||
|
import { site } from '../data/site';
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const socials = [
|
||||||
|
{ ...site.links.gitea, Comp: Gitea },
|
||||||
|
{ ...site.links.github, Comp: Github },
|
||||||
|
{ ...site.links.npm, Comp: Npm },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="footer__inner">
|
||||||
|
<div class="footer__brand">
|
||||||
|
<span class="footer__mark" aria-hidden="true"></span>
|
||||||
|
<div>
|
||||||
|
<p class="footer__name">{site.name}</p>
|
||||||
|
<p class="footer__role">{site.role} · {site.location}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="footer__links" aria-label="Liens externes">
|
||||||
|
{
|
||||||
|
socials.map((s) => (
|
||||||
|
<a class="footer__link" href={s.url} target="_blank" rel="noopener noreferrer" title={s.label}>
|
||||||
|
<s.Comp size={18} fill="currentColor" />
|
||||||
|
<span>{s.label}</span>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<a class="footer__link" href={site.links.linkedin.url} target="_blank" rel="noopener noreferrer" title="LinkedIn">
|
||||||
|
<BrandLinkedin size={18} />
|
||||||
|
<span>LinkedIn</span>
|
||||||
|
</a>
|
||||||
|
<a class="footer__link" href={`mailto:${site.email}`} title="E-mail">
|
||||||
|
<Mail size={18} />
|
||||||
|
<span>E-mail</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer__base">
|
||||||
|
<span>© {year} {site.name}</span>
|
||||||
|
<span class="footer__sep" aria-hidden="true">·</span>
|
||||||
|
<span>Bâti avec Astro + Nova Design System</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use 'styles/type' as *;
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
border-top: var(--nds-border-width-thin) solid
|
||||||
|
color-mix(in srgb, var(--nds-border) 80%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--nds-surface) 55%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
margin-top: var(--nds-spacing-3xl);
|
||||||
|
}
|
||||||
|
.footer__inner {
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--nds-spacing-2xl) clamp(20px, 5vw, 56px) var(--nds-spacing-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--nds-spacing-lg);
|
||||||
|
}
|
||||||
|
.footer__brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-sm);
|
||||||
|
}
|
||||||
|
.footer__mark {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: var(--nds-radius-md);
|
||||||
|
background: linear-gradient(135deg, var(--nds-primary), var(--nds-accent));
|
||||||
|
box-shadow: 0 0 18px color-mix(in srgb, var(--nds-primary) 45%, transparent);
|
||||||
|
}
|
||||||
|
.footer__name {
|
||||||
|
@include text-xl;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.footer__role {
|
||||||
|
@include text-sm;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 2px 0 0;
|
||||||
|
}
|
||||||
|
.footer__links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--nds-spacing-md);
|
||||||
|
}
|
||||||
|
.footer__link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-xs);
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
text-decoration: none;
|
||||||
|
@include text-label;
|
||||||
|
transition: color 140ms ease;
|
||||||
|
&:hover {
|
||||||
|
color: var(--nds-primary);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px var(--nds-ring);
|
||||||
|
border-radius: var(--nds-radius-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.footer__base {
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: var(--nds-spacing-md) clamp(20px, 5vw, 56px) var(--nds-spacing-2xl);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-xs);
|
||||||
|
border-top: var(--nds-border-width-thin) solid
|
||||||
|
color-mix(in srgb, var(--nds-border) 50%, transparent);
|
||||||
|
@include text-sm;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
}
|
||||||
|
.footer__sep {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
import { Navbar, Button } from '@unkn0wndo3s/nova-design-system';
|
||||||
|
import { Send } from '@lucide/astro';
|
||||||
|
|
||||||
|
const path = Astro.url.pathname.replace(/\/$/, '') || '/';
|
||||||
|
const links = [
|
||||||
|
{ href: '/', label: 'Accueil' },
|
||||||
|
{ href: '/work', label: 'Projets' },
|
||||||
|
{ href: '/about', label: 'À propos' },
|
||||||
|
{ href: '/contact', label: 'Contact' },
|
||||||
|
];
|
||||||
|
const isActive = (href: string) =>
|
||||||
|
href === '/' ? path === '/' : path === href || path.startsWith(href + '/');
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="site-nav">
|
||||||
|
<Navbar brand="L · POTEVIN">
|
||||||
|
{
|
||||||
|
links.map((l) => (
|
||||||
|
<a
|
||||||
|
class={`navbar__link ${isActive(l.href) ? 'navbar__link--active' : ''}`}
|
||||||
|
href={l.href}
|
||||||
|
aria-current={isActive(l.href) ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<Button slot="right" type="primary" size="sm" href="/contact">
|
||||||
|
<Send slot="icon-left" size={15} />
|
||||||
|
Établir le contact
|
||||||
|
</Button>
|
||||||
|
</Navbar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.site-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 60;
|
||||||
|
|
||||||
|
// Nova Navbar rendered translucent to let the starfield show through.
|
||||||
|
:global(.navbar) {
|
||||||
|
background: color-mix(in srgb, var(--nds-background) 72%, transparent);
|
||||||
|
backdrop-filter: blur(14px) saturate(1.1);
|
||||||
|
-webkit-backdrop-filter: blur(14px) saturate(1.1);
|
||||||
|
border-bottom: var(--nds-border-width-thin) solid
|
||||||
|
color-mix(in srgb, var(--nds-border) 80%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.site-nav :global(.navbar__links) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
// Persistent space background. Placed in the layout → loaded on all pages.
|
||||||
|
// Content remains 100% readable and functional even if WebGL fails (decorative).
|
||||||
|
---
|
||||||
|
|
||||||
|
<canvas id="space-canvas" class="space-canvas" aria-hidden="true"></canvas>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.space-canvas {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 0;
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
// slight overlay tint to ground the content on top
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
120% 80% at 50% -10%,
|
||||||
|
color-mix(in srgb, var(--nds-primary) 7%, transparent),
|
||||||
|
transparent 60%
|
||||||
|
),
|
||||||
|
var(--nds-background);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { initScene, type SceneHandle } from './scene/scene';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__novaScene?: SceneHandle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.getElementById('space-canvas') as HTMLCanvasElement | null;
|
||||||
|
|
||||||
|
if (canvas && !window.__novaScene) {
|
||||||
|
try {
|
||||||
|
const handle = initScene(canvas);
|
||||||
|
window.__novaScene = handle;
|
||||||
|
// notify page scripts that the scene is ready
|
||||||
|
window.dispatchEvent(new CustomEvent<SceneHandle>('nova:scene-ready', { detail: handle }));
|
||||||
|
} catch (err) {
|
||||||
|
// WebGL unavailable: fail silently, the website remains fully usable.
|
||||||
|
console.warn('[nova] 3D background disabled:', err);
|
||||||
|
canvas.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
// Monochrome tech icon (inherits `color` via fill=currentColor).
|
||||||
|
import {
|
||||||
|
Typescript,
|
||||||
|
Javascript,
|
||||||
|
Postgresql,
|
||||||
|
Docker,
|
||||||
|
Astro as AstroIcon,
|
||||||
|
Sass,
|
||||||
|
Gitea,
|
||||||
|
Linux,
|
||||||
|
Cloudflare,
|
||||||
|
Threedotjs,
|
||||||
|
Rust,
|
||||||
|
Python,
|
||||||
|
Npm,
|
||||||
|
Github,
|
||||||
|
Svelte,
|
||||||
|
Git,
|
||||||
|
Nodedotjs,
|
||||||
|
} from 'simple-icons-astro';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
const { name, size = 18 } = Astro.props;
|
||||||
|
|
||||||
|
const map: Record<string, any> = {
|
||||||
|
typescript: Typescript,
|
||||||
|
javascript: Javascript,
|
||||||
|
postgresql: Postgresql,
|
||||||
|
docker: Docker,
|
||||||
|
astro: AstroIcon,
|
||||||
|
sass: Sass,
|
||||||
|
gitea: Gitea,
|
||||||
|
linux: Linux,
|
||||||
|
cloudflare: Cloudflare,
|
||||||
|
threedotjs: Threedotjs,
|
||||||
|
rust: Rust,
|
||||||
|
python: Python,
|
||||||
|
npm: Npm,
|
||||||
|
github: Github,
|
||||||
|
svelte: Svelte,
|
||||||
|
git: Git,
|
||||||
|
nodedotjs: Nodedotjs,
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = map[name];
|
||||||
|
---
|
||||||
|
|
||||||
|
{Icon && <Icon size={size} fill="currentColor" />}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Ambient space background: starfield, subtle nebula, and a slow-drifting,
|
||||||
|
// slow-rotating asteroid. No scroll binding, no engine flames.
|
||||||
|
// Slight pointer parallax. Respects prefers-reduced-motion.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SceneHandle {
|
||||||
|
/** Kept for compatibility — no-op (no more impact/flame effect). */
|
||||||
|
setImpact(value: number): void;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
star: 0xecf5f5,
|
||||||
|
starCool: 0x34c2c9,
|
||||||
|
starAccent: 0x2efafa,
|
||||||
|
nebulaA: 0x288181,
|
||||||
|
nebulaB: 0x1fa6ad,
|
||||||
|
rock: 0x1a2a2a,
|
||||||
|
rim: 0x34c2c9,
|
||||||
|
};
|
||||||
|
|
||||||
|
const prefersReduced =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
/** Small reusable radial texture (soft glow). */
|
||||||
|
function radialTexture(inner: string, outer = 'rgba(0,0,0,0)'): THREE.Texture {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = c.height = 128;
|
||||||
|
const ctx = c.getContext('2d')!;
|
||||||
|
const g = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
|
||||||
|
g.addColorStop(0, inner);
|
||||||
|
g.addColorStop(0.4, inner);
|
||||||
|
g.addColorStop(1, outer);
|
||||||
|
ctx.fillStyle = g;
|
||||||
|
ctx.fillRect(0, 0, 128, 128);
|
||||||
|
const tex = new THREE.CanvasTexture(c);
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initScene(canvas: HTMLCanvasElement): SceneHandle {
|
||||||
|
const renderer = new THREE.WebGLRenderer({
|
||||||
|
canvas,
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
powerPreference: 'high-performance',
|
||||||
|
});
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
renderer.setClearColor(0x000000, 0);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.fog = new THREE.FogExp2(0x040b0b, 0.012);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(
|
||||||
|
55,
|
||||||
|
window.innerWidth / window.innerHeight,
|
||||||
|
0.1,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
camera.position.set(0, 0, 18);
|
||||||
|
|
||||||
|
// ── Lighting (cold, consistent with Nova tokens) ───────────────────────────
|
||||||
|
const ambient = new THREE.AmbientLight(0x16323a, 1.0);
|
||||||
|
scene.add(ambient);
|
||||||
|
const key = new THREE.DirectionalLight(COLORS.starCool, 0.9);
|
||||||
|
key.position.set(-6, 5, 9);
|
||||||
|
scene.add(key);
|
||||||
|
const rimLight = new THREE.DirectionalLight(COLORS.starAccent, 0.4);
|
||||||
|
rimLight.position.set(8, -3, 4);
|
||||||
|
scene.add(rimLight);
|
||||||
|
|
||||||
|
// ── Starfield ──────────────────────────────────────────────────────────────
|
||||||
|
const STAR_COUNT = prefersReduced ? 700 : 1700;
|
||||||
|
const starGeo = new THREE.BufferGeometry();
|
||||||
|
const starPos = new Float32Array(STAR_COUNT * 3);
|
||||||
|
const starCol = new Float32Array(STAR_COUNT * 3);
|
||||||
|
const palette = [
|
||||||
|
new THREE.Color(COLORS.star),
|
||||||
|
new THREE.Color(COLORS.starCool),
|
||||||
|
new THREE.Color(COLORS.starAccent),
|
||||||
|
];
|
||||||
|
for (let i = 0; i < STAR_COUNT; i++) {
|
||||||
|
starPos[i * 3] = (Math.random() - 0.5) * 120;
|
||||||
|
starPos[i * 3 + 1] = (Math.random() - 0.5) * 120;
|
||||||
|
starPos[i * 3 + 2] = (Math.random() - 0.5) * 120 - 20;
|
||||||
|
const col = palette[Math.random() < 0.78 ? 0 : Math.random() < 0.6 ? 1 : 2];
|
||||||
|
starCol[i * 3] = col.r;
|
||||||
|
starCol[i * 3 + 1] = col.g;
|
||||||
|
starCol[i * 3 + 2] = col.b;
|
||||||
|
}
|
||||||
|
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
|
||||||
|
starGeo.setAttribute('color', new THREE.BufferAttribute(starCol, 3));
|
||||||
|
const starMat = new THREE.PointsMaterial({
|
||||||
|
size: 0.42,
|
||||||
|
map: radialTexture('rgba(255,255,255,0.95)'),
|
||||||
|
transparent: true,
|
||||||
|
depthWrite: false,
|
||||||
|
vertexColors: true,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
});
|
||||||
|
const stars = new THREE.Points(starGeo, starMat);
|
||||||
|
scene.add(stars);
|
||||||
|
|
||||||
|
// ── Nebula (soft sprites, pushed far into the background) ──────────────────
|
||||||
|
const nebula = new THREE.Group();
|
||||||
|
const nebTex = radialTexture('rgba(40,129,129,0.55)');
|
||||||
|
[
|
||||||
|
{ x: -14, y: 8, z: -45, s: 60, c: COLORS.nebulaA },
|
||||||
|
{ x: 16, y: -10, z: -55, s: 72, c: COLORS.nebulaB },
|
||||||
|
{ x: 4, y: 14, z: -40, s: 46, c: COLORS.nebulaA },
|
||||||
|
].forEach((n) => {
|
||||||
|
const mat = new THREE.SpriteMaterial({
|
||||||
|
map: nebTex,
|
||||||
|
color: n.c,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.2,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
});
|
||||||
|
const sp = new THREE.Sprite(mat);
|
||||||
|
sp.position.set(n.x, n.y, n.z);
|
||||||
|
sp.scale.set(n.s, n.s, 1);
|
||||||
|
nebula.add(sp);
|
||||||
|
});
|
||||||
|
scene.add(nebula);
|
||||||
|
|
||||||
|
// ── Asteroid (cold rock, subtle halo) ──────────────────────────────────────
|
||||||
|
const asteroid = new THREE.Group();
|
||||||
|
const rockGeo = new THREE.IcosahedronGeometry(1.9, 2);
|
||||||
|
const pos = rockGeo.attributes.position as THREE.BufferAttribute;
|
||||||
|
const v = new THREE.Vector3();
|
||||||
|
for (let i = 0; i < pos.count; i++) {
|
||||||
|
v.fromBufferAttribute(pos, i);
|
||||||
|
const h = Math.sin(v.x * 4.1) * Math.cos(v.y * 3.7) * Math.sin(v.z * 4.3);
|
||||||
|
const d = 1 + h * 0.16;
|
||||||
|
v.multiplyScalar(d);
|
||||||
|
pos.setXYZ(i, v.x, v.y, v.z);
|
||||||
|
}
|
||||||
|
rockGeo.computeVertexNormals();
|
||||||
|
const rockMat = new THREE.MeshStandardMaterial({
|
||||||
|
color: COLORS.rock,
|
||||||
|
emissive: COLORS.rim,
|
||||||
|
emissiveIntensity: 0.12,
|
||||||
|
roughness: 0.92,
|
||||||
|
metalness: 0.12,
|
||||||
|
flatShading: true,
|
||||||
|
});
|
||||||
|
const rock = new THREE.Mesh(rockGeo, rockMat);
|
||||||
|
asteroid.add(rock);
|
||||||
|
|
||||||
|
// very light cold halo, just to separate the rock from the background
|
||||||
|
const glow = new THREE.Sprite(
|
||||||
|
new THREE.SpriteMaterial({
|
||||||
|
map: radialTexture('rgba(52,194,201,0.7)'),
|
||||||
|
color: COLORS.rim,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.28,
|
||||||
|
blending: THREE.AdditiveBlending,
|
||||||
|
depthWrite: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
glow.scale.set(7.5, 7.5, 1);
|
||||||
|
asteroid.add(glow);
|
||||||
|
|
||||||
|
// ambient position: top right, set back
|
||||||
|
asteroid.position.set(6.5, 4.5, -2);
|
||||||
|
scene.add(asteroid);
|
||||||
|
|
||||||
|
// ── State & loop ───────────────────────────────────────────────────────────
|
||||||
|
const pointer = { x: 0, y: 0, tx: 0, ty: 0 };
|
||||||
|
let running = true;
|
||||||
|
|
||||||
|
function onPointer(e: PointerEvent) {
|
||||||
|
pointer.tx = (e.clientX / window.innerWidth - 0.5) * 2;
|
||||||
|
pointer.ty = (e.clientY / window.innerHeight - 0.5) * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
camera.aspect = window.innerWidth / window.innerHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibility() {
|
||||||
|
running = document.visibilityState === 'visible';
|
||||||
|
if (running) loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
window.addEventListener('pointermove', onPointer, { passive: true });
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
|
||||||
|
const clock = new THREE.Clock();
|
||||||
|
let rafId = 0;
|
||||||
|
|
||||||
|
function loop() {
|
||||||
|
if (!running) return;
|
||||||
|
rafId = requestAnimationFrame(loop);
|
||||||
|
const dt = Math.min(clock.getDelta(), 0.05);
|
||||||
|
const t = clock.elapsedTime;
|
||||||
|
|
||||||
|
// slow background drift
|
||||||
|
stars.rotation.y += dt * 0.01;
|
||||||
|
nebula.rotation.z += dt * 0.004;
|
||||||
|
|
||||||
|
// asteroid: self-rotation + slight float (no scroll binding)
|
||||||
|
if (!prefersReduced) {
|
||||||
|
rock.rotation.x += dt * 0.18;
|
||||||
|
rock.rotation.y += dt * 0.26;
|
||||||
|
asteroid.position.y = 4.5 + Math.sin(t * 0.25) * 0.5;
|
||||||
|
asteroid.position.x = 6.5 + Math.cos(t * 0.18) * 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// smooth camera parallax via pointer
|
||||||
|
pointer.x += (pointer.tx - pointer.x) * dt * 2;
|
||||||
|
pointer.y += (pointer.ty - pointer.y) * dt * 2;
|
||||||
|
camera.position.x = pointer.x * 1.2;
|
||||||
|
camera.position.y = -pointer.y * 0.8 + Math.sin(t * 0.3) * 0.15;
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
loop();
|
||||||
|
|
||||||
|
return {
|
||||||
|
setImpact() {
|
||||||
|
/* no-op: kept for API compatibility */
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
running = false;
|
||||||
|
cancelAnimationFrame(rafId);
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
window.removeEventListener('pointermove', onPointer);
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
renderer.dispose();
|
||||||
|
starGeo.dispose();
|
||||||
|
rockGeo.dispose();
|
||||||
|
scene.traverse((o) => {
|
||||||
|
const m = (o as THREE.Mesh).material;
|
||||||
|
if (Array.isArray(m)) m.forEach((mm) => mm.dispose());
|
||||||
|
else if (m) (m as THREE.Material).dispose();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Projects — real content. Edit freely.
|
||||||
|
// `icon` matches a simple-icons-astro component (using PascalCase elsewhere).
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ProjectStatus = 'live' | 'maintenu' | 'archive' | 'r&d';
|
||||||
|
|
||||||
|
export interface ProjectLink {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
slug: string;
|
||||||
|
designation: string; // catalog index, e.g. "01"
|
||||||
|
title: string;
|
||||||
|
tagline: string;
|
||||||
|
period: string;
|
||||||
|
role: string;
|
||||||
|
status: ProjectStatus;
|
||||||
|
featured: boolean;
|
||||||
|
summary: string;
|
||||||
|
context: string[];
|
||||||
|
contributions: string[];
|
||||||
|
stack: { icon: string; label: string }[];
|
||||||
|
links: ProjectLink[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projects: Project[] = [
|
||||||
|
{
|
||||||
|
slug: 'nova-design-system',
|
||||||
|
designation: '01',
|
||||||
|
title: 'Nova Design System',
|
||||||
|
tagline: 'Astro component library — tokens, accessibility, npm distribution.',
|
||||||
|
period: '2026 — ongoing',
|
||||||
|
role: 'Author · maintainer',
|
||||||
|
status: 'maintenu',
|
||||||
|
featured: true,
|
||||||
|
summary:
|
||||||
|
'The design system that powers this website and will power my other projects. 29 Astro components across six families, a complete set of tokens (color, typography, spacing, radius), and a light/dark theme managed via CSS variables.',
|
||||||
|
context: [
|
||||||
|
'Designed for clean, technical interfaces: mono fonts for headings, sans-serif for body text.',
|
||||||
|
'Distributed under the npm scope @unkn0wndo3s, automatically published via a CI pipeline.',
|
||||||
|
'License: free to use, resale of the design system as a standalone product is prohibited.',
|
||||||
|
],
|
||||||
|
contributions: [
|
||||||
|
'Architecture of tokens and components (Custom Elements, typed events, slots).',
|
||||||
|
'Gitea Actions CI/CD pipeline for automated npm publishing and documentation deployment.',
|
||||||
|
],
|
||||||
|
stack: [
|
||||||
|
{ icon: 'astro', label: 'Astro' },
|
||||||
|
{ icon: 'typescript', label: 'TypeScript' },
|
||||||
|
{ icon: 'sass', label: 'Sass' },
|
||||||
|
{ icon: 'gitea', label: 'Gitea CI' },
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ label: 'Nova Design System v1.2.1', url: 'https://nds.louis-potevin.dev/', icon: 'astro' },
|
||||||
|
{ label: 'npm', url: 'https://www.npmjs.com/package/@unkn0wndo3s/nova-design-system', icon: 'npm' },
|
||||||
|
{ label: 'Sources (Gitea)', url: 'https://git.novaprojects.dev/unkn0wn/nova-design-system', icon: 'gitea' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'nova-infra',
|
||||||
|
designation: '02',
|
||||||
|
title: 'Self-hosted Infrastructure & Deployment',
|
||||||
|
tagline: 'Containerized services, CI/CD, and secured ingress on a fully controlled infrastructure.',
|
||||||
|
period: '2025 — ongoing',
|
||||||
|
role: 'Design & operations',
|
||||||
|
status: 'live',
|
||||||
|
featured: true,
|
||||||
|
summary:
|
||||||
|
'A comprehensive self-hosted infrastructure: Git hosting, secret management, and application services, all containerized with Docker and continuously deployed.',
|
||||||
|
context: [
|
||||||
|
'Fully containerized with Docker, orchestrated, and reproducible.',
|
||||||
|
'Certificates and DNS managed through Cloudflare.',
|
||||||
|
'Services cleanly exposed using a reverse proxy.',
|
||||||
|
],
|
||||||
|
contributions: [
|
||||||
|
'Setup of a self-hosted Gitea instance with a Postgres backend and action runners.',
|
||||||
|
'End-to-end automated CI/CD pipelines (build, test, publish).',
|
||||||
|
'Day-to-day operations: Docker networks, volume mounts, DNS, and diagnostics.',
|
||||||
|
],
|
||||||
|
stack: [
|
||||||
|
{ icon: 'docker', label: 'Docker' },
|
||||||
|
{ icon: 'linux', label: 'Linux' },
|
||||||
|
{ icon: 'cloudflare', label: 'Cloudflare Tunnel' },
|
||||||
|
{ icon: 'gitea', label: 'Gitea' },
|
||||||
|
],
|
||||||
|
links: [{ label: 'Gitea', url: 'https://git.novaprojects.dev/unkn0wn', icon: 'gitea' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'portfolio',
|
||||||
|
designation: '03',
|
||||||
|
title: 'louis-potevin.dev',
|
||||||
|
tagline: 'This website. Multi-page Astro app, 3D space background, 100% powered by Nova Design System.',
|
||||||
|
period: '2026',
|
||||||
|
role: 'Design & development',
|
||||||
|
status: 'live',
|
||||||
|
featured: true,
|
||||||
|
summary:
|
||||||
|
'A static multi-page portfolio website. The content is entirely built using Nova components, featuring a subtle, discrete Three.js space background.',
|
||||||
|
context: [
|
||||||
|
'Three.js space background: starfield, nebula, and an ambient asteroid.',
|
||||||
|
'UI entirely composed using the in-house design system.',
|
||||||
|
'Astro multi-page setup with Static Site Generation (SSG), optimized for SEO.',
|
||||||
|
],
|
||||||
|
contributions: [
|
||||||
|
'Full integration using Nova tokens and components.',
|
||||||
|
'Adherence to prefers-reduced-motion and graceful degradation when WebGL is unavailable.',
|
||||||
|
'Meticulous SEO: metadata, structured data, and sitemap generation.',
|
||||||
|
],
|
||||||
|
stack: [
|
||||||
|
{ icon: 'astro', label: 'Astro' },
|
||||||
|
{ icon: 'typescript', label: 'TypeScript' },
|
||||||
|
{ icon: 'sass', label: 'Sass' },
|
||||||
|
],
|
||||||
|
links: [{ label: 'Unkn0wn Projects', url: 'https://louis-potevin.dev/', icon: 'astro' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'rust-file-organizer',
|
||||||
|
designation: '04',
|
||||||
|
title: 'File Organizer — Java to Rust',
|
||||||
|
tagline: 'File sorting tool, currently being ported from Java to Rust.',
|
||||||
|
period: '2025 — ongoing',
|
||||||
|
role: 'Development',
|
||||||
|
status: 'r&d',
|
||||||
|
featured: false,
|
||||||
|
summary:
|
||||||
|
'A file organizer tool that currently runs in Java on Windows. It is being converted to Rust to achieve cross-platform Linux compatibility and optimize file search performance.',
|
||||||
|
context: [
|
||||||
|
'Current version: operational in Java, targeting Windows.',
|
||||||
|
'Gradual porting to Rust for performance gains and low-level control.',
|
||||||
|
],
|
||||||
|
contributions: [
|
||||||
|
'Rewriting sorting logic from Java to Rust.',
|
||||||
|
'Setting objectives for Linux compatibility and file search optimization.',
|
||||||
|
],
|
||||||
|
stack: [
|
||||||
|
{ icon: 'rust', label: 'Rust' },
|
||||||
|
{ icon: 'linux', label: 'Linux (upcoming)' },
|
||||||
|
],
|
||||||
|
links: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'llm-tooling',
|
||||||
|
designation: '05',
|
||||||
|
title: 'LLM Training & Inference',
|
||||||
|
tagline: 'Small language model trained on a Reddit conversation dataset.',
|
||||||
|
period: '2025 — ongoing',
|
||||||
|
role: 'Development',
|
||||||
|
status: 'r&d',
|
||||||
|
featured: false,
|
||||||
|
summary:
|
||||||
|
'A language model training and inference project running on a dedicated machine. The model is trained on a custom dataset of Reddit conversations. Development is ongoing.',
|
||||||
|
context: [
|
||||||
|
'Execution on a dedicated workstation optimized for training and inference.',
|
||||||
|
'Dataset: conversation threads sourced from Reddit.',
|
||||||
|
],
|
||||||
|
contributions: [
|
||||||
|
'Assembling and preprocessing the conversation dataset.',
|
||||||
|
'Setting up the training and inference loop, currently iterating on results.',
|
||||||
|
],
|
||||||
|
stack: [
|
||||||
|
{ icon: 'python', label: 'Python' },
|
||||||
|
{ icon: 'linux', label: 'Linux' },
|
||||||
|
],
|
||||||
|
links: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const featuredProjects = projects.filter((p) => p.featured);
|
||||||
|
|
||||||
|
export const statusMeta: Record<ProjectStatus, { label: string; tone: 'success' | 'primary' | 'neutral' | 'warning' }> = {
|
||||||
|
live: { label: 'Live', tone: 'success' },
|
||||||
|
maintenu: { label: 'Maintained', tone: 'primary' },
|
||||||
|
'r&d': { label: 'In Progress', tone: 'warning' },
|
||||||
|
archive: { label: 'Archived', tone: 'neutral' },
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export const site = {
|
||||||
|
name: "Louis Potevin",
|
||||||
|
handle: "unkn0wn",
|
||||||
|
role: "Full-stack developer",
|
||||||
|
domain: "louis-potevin.dev",
|
||||||
|
location: "Limoges · Nouvelle-Aquitaine",
|
||||||
|
// Job Search
|
||||||
|
availability: "Permanent contract (CDI) — September 2026",
|
||||||
|
mobility: "Anywhere in France, on-site or remote",
|
||||||
|
// Contact
|
||||||
|
email: "contact@louis-potevin.dev",
|
||||||
|
// External links (icons via simple-icons-astro)
|
||||||
|
links: {
|
||||||
|
gitea: {
|
||||||
|
label: "Gitea",
|
||||||
|
url: "https://git.novaprojects.dev/unkn0wn",
|
||||||
|
icon: "gitea",
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
label: "GitHub",
|
||||||
|
url: "https://github.com/unkn0wndo3s",
|
||||||
|
icon: "github",
|
||||||
|
},
|
||||||
|
npm: {
|
||||||
|
label: "npm",
|
||||||
|
url: "https://www.npmjs.com/~unkn0wndo3s",
|
||||||
|
icon: "npm",
|
||||||
|
},
|
||||||
|
linkedin: {
|
||||||
|
label: "LinkedIn",
|
||||||
|
url: "https://www.linkedin.com/in/louis-potevin",
|
||||||
|
icon: "linkedin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SiteLink = { label: string; url: string; icon: string };
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
---
|
||||||
|
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';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
/** Page-specific keywords (appended to the global keywords list). */
|
||||||
|
keywords?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title = 'Louis Potevin — Full-stack Developer (front, back, web)',
|
||||||
|
description = "Louis Potevin, full-stack web developer: front-end, back-end, TypeScript, Astro, Node. Available for a permanent contract (CDI) starting September 2026, anywhere in France or remote.",
|
||||||
|
keywords = [],
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const baseKeywords = [
|
||||||
|
// French Keywords
|
||||||
|
'Louis Potevin',
|
||||||
|
'développeur web',
|
||||||
|
'developpeur web',
|
||||||
|
'dev web',
|
||||||
|
'développeur full-stack',
|
||||||
|
'developpeur full stack',
|
||||||
|
'dev front',
|
||||||
|
'développeur front-end',
|
||||||
|
'dev back',
|
||||||
|
'développeur back-end',
|
||||||
|
'développeur TypeScript',
|
||||||
|
'développeur Astro',
|
||||||
|
'développeur JavaScript',
|
||||||
|
'développeur Node.js',
|
||||||
|
'développeur freelance France',
|
||||||
|
'portfolio développeur',
|
||||||
|
'web developer',
|
||||||
|
'full-stack developer',
|
||||||
|
'full stack developer',
|
||||||
|
'front-end developer',
|
||||||
|
'front end dev',
|
||||||
|
'back-end developer',
|
||||||
|
'back end dev',
|
||||||
|
'TypeScript developer',
|
||||||
|
'Astro developer',
|
||||||
|
'JavaScript developer',
|
||||||
|
'Node.js developer',
|
||||||
|
'software engineer France',
|
||||||
|
'developer portfolio',
|
||||||
|
];
|
||||||
|
const allKeywords = [...baseKeywords, ...keywords].join(', ');
|
||||||
|
|
||||||
|
const canonical = new URL(Astro.url.pathname, Astro.site ?? 'https://louis-potevin.dev').href;
|
||||||
|
const ogImage = new URL('/og.png', Astro.site ?? 'https://louis-potevin.dev').href;
|
||||||
|
|
||||||
|
// Structured data: Person profile (helps Google link your name ↔ profession ↔ social links).
|
||||||
|
const personLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Person',
|
||||||
|
name: site.name,
|
||||||
|
jobTitle: 'Full-stack Developer / Développeur full-stack',
|
||||||
|
description,
|
||||||
|
url: canonical,
|
||||||
|
email: `mailto:${site.email}`,
|
||||||
|
address: { '@type': 'PostalAddress', addressCountry: 'FR', addressLocality: 'Limoges' },
|
||||||
|
knowsAbout: [
|
||||||
|
'Web development',
|
||||||
|
'Développement web',
|
||||||
|
'Front-end',
|
||||||
|
'Back-end',
|
||||||
|
'TypeScript',
|
||||||
|
'JavaScript',
|
||||||
|
'Astro',
|
||||||
|
'Node.js',
|
||||||
|
'Sass',
|
||||||
|
'Docker',
|
||||||
|
'CI/CD',
|
||||||
|
],
|
||||||
|
sameAs: [
|
||||||
|
site.links.github.url,
|
||||||
|
site.links.gitea.url,
|
||||||
|
site.links.npm.url,
|
||||||
|
site.links.linkedin.url,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const siteLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: `${site.name} — Portfolio`,
|
||||||
|
url: Astro.site?.href ?? 'https://louis-potevin.dev',
|
||||||
|
inLanguage: ['en-US', 'fr-FR'],
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" 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" />
|
||||||
|
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<meta name="keywords" content={allKeywords} />
|
||||||
|
<meta name="author" content={site.name} />
|
||||||
|
<meta name="robots" content="index, follow, max-image-preview:large" />
|
||||||
|
<link rel="canonical" href={canonical} />
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content={`${site.name} — Portfolio`} />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
<meta property="og:locale:alternate" content="fr_FR" />
|
||||||
|
<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 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" />
|
||||||
|
|
||||||
|
<script type="application/ld+json" set:html={JSON.stringify(personLd)} />
|
||||||
|
<script type="application/ld+json" set:html={JSON.stringify(siteLd)} />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<SpaceBackground />
|
||||||
|
<a class="skip" href="#content">Skip to content</a>
|
||||||
|
<SiteNav />
|
||||||
|
<main id="content" class="shell">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<SiteFooter />
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.shell {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
max-width: 1120px;
|
||||||
|
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,315 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import Eyebrow from '../components/Eyebrow.astro';
|
||||||
|
import TechIcon from '../components/TechIcon.astro';
|
||||||
|
import BrandLinkedin from '../components/BrandLinkedin.astro';
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Link,
|
||||||
|
ListItem,
|
||||||
|
ListItemTitle,
|
||||||
|
ListItemSubtitle,
|
||||||
|
} from '@unkn0wndo3s/nova-design-system';
|
||||||
|
import { Send, Mail } from '@lucide/astro';
|
||||||
|
import { site } from '../data/site';
|
||||||
|
|
||||||
|
const title = 'About — Louis Potevin, full-stack web developer / développeur full-stack';
|
||||||
|
const description =
|
||||||
|
"Louis Potevin, full-stack web developer with a BUT MMI degree (DWDI track). 18 months of professional experience at Legrand France. Available for a permanent contract (CDI), anywhere in France or remote.";
|
||||||
|
|
||||||
|
const parcours = [
|
||||||
|
{
|
||||||
|
year: '2024 — 2026',
|
||||||
|
title: 'Professional Experience · Legrand France',
|
||||||
|
body: "18 months total: 12 weeks of internship, 1 month on a fixed-term contract (CDD), followed by 1 year of work-study apprenticeship. Web development on a production-ready internal corporate application.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: '2024 — 2026',
|
||||||
|
title: 'BUT MMI — DWDI Track · IUT du Limousin',
|
||||||
|
body: "Multimedia and Internet Job Degrees (Métiers du Multimédia et de l'Internet), specializing in Web Development and Interactive Devices (DWDI). Degree completed in 2026.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
year: '2026 — ongoing',
|
||||||
|
title: 'Personal Projects',
|
||||||
|
body: "Designing an open-source design system published on npm, managing a self-hosted server infrastructure, and building system tools with Rust and Python.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const stack = [
|
||||||
|
{ icon: 'astro', label: 'Astro' },
|
||||||
|
{ icon: 'typescript', label: 'TypeScript' },
|
||||||
|
{ icon: 'javascript', label: 'JavaScript' },
|
||||||
|
{ icon: 'sass', label: 'Sass' },
|
||||||
|
{ icon: 'postgresql', label: 'PostgreSQL' },
|
||||||
|
{ icon: 'docker', label: 'Docker' },
|
||||||
|
{ icon: 'linux', label: 'Linux' },
|
||||||
|
{ icon: 'cloudflare', label: 'Cloudflare' },
|
||||||
|
{ icon: 'gitea', label: 'Gitea' },
|
||||||
|
{ icon: 'rust', label: 'Rust' },
|
||||||
|
{ icon: 'python', label: 'Python' },
|
||||||
|
{ icon: 'git', label: 'Git' },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={title} description={description}>
|
||||||
|
<section class="head">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbItem href="/">Home</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem href="/about" current>About</BreadcrumbItem>
|
||||||
|
</Breadcrumb>
|
||||||
|
<Eyebrow index="//" label="About" />
|
||||||
|
<h1 class="head__title">About me</h1>
|
||||||
|
<p class="head__lede">
|
||||||
|
Full-stack developer based in {site.location}. I design complete web applications,
|
||||||
|
from front-end interfaces to back-end architecture, and manage their deployment into production.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<div class="layout__main">
|
||||||
|
<section class="block">
|
||||||
|
<Eyebrow index="01" label="Overview" />
|
||||||
|
<p class="prose">
|
||||||
|
I graduated with a BUT MMI degree, specializing in Web Development and Interactive
|
||||||
|
Devices (DWDI) at the IUT du Limousin. I solidified my practical engineering skills
|
||||||
|
in the industry: spending 18 months at Legrand France split between a 12-week internship,
|
||||||
|
a 1-month contract, and a full year as a software apprentice, working on a business-critical
|
||||||
|
production application.
|
||||||
|
</p>
|
||||||
|
<p class="prose">
|
||||||
|
Joining Legrand as an apprentice without knowing anyone prior, I quickly integrated
|
||||||
|
into the team and built strong working relationships across departments. I am equally
|
||||||
|
comfortable collaborating in agile team structures as I am working independently, and I excel
|
||||||
|
at adapting swiftly to new technical environments.
|
||||||
|
</p>
|
||||||
|
<p class="prose">
|
||||||
|
Outside of my professional employment, I build and maintain my own digital infrastructure:
|
||||||
|
a modular design system published on npm, a private self-hosted suite of services, and custom
|
||||||
|
automation tools written in Rust and Python. This is my preferred way of keeping up with industry
|
||||||
|
standards and thoroughly exploring engineering topics end-to-end.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<Eyebrow index="02" label="Timeline" />
|
||||||
|
<ol class="timeline">
|
||||||
|
{
|
||||||
|
parcours.map((p) => (
|
||||||
|
<li class="timeline__item">
|
||||||
|
<span class="timeline__year">{p.year}</span>
|
||||||
|
<div class="timeline__card">
|
||||||
|
<ListItem>
|
||||||
|
<ListItemTitle>{p.title}</ListItemTitle>
|
||||||
|
<ListItemSubtitle>{p.body}</ListItemSubtitle>
|
||||||
|
</ListItem>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block">
|
||||||
|
<Eyebrow index="03" label="Toolkit" />
|
||||||
|
<ul class="chips">
|
||||||
|
{
|
||||||
|
stack.map((s) => (
|
||||||
|
<li class="chip">
|
||||||
|
<TechIcon name={s.icon} size={15} />
|
||||||
|
{s.label}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="layout__side">
|
||||||
|
<div class="panel panel--accent">
|
||||||
|
<p class="panel__title">Status</p>
|
||||||
|
<p class="panel__big">{site.availability}</p>
|
||||||
|
<div class="panel__badge">
|
||||||
|
<Badge type="success" variant="soft">Available</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="panel__mobility">{site.mobility}.</p>
|
||||||
|
<Button type="primary" href="/contact">
|
||||||
|
<Send slot="icon-left" size={15} />
|
||||||
|
Get in touch
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<p class="panel__title">Profiles</p>
|
||||||
|
<ul class="links">
|
||||||
|
<li>
|
||||||
|
<Link url={site.links.github.url} blank>
|
||||||
|
<TechIcon name="github" size={15} /> GitHub
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link url={site.links.gitea.url} blank>
|
||||||
|
<TechIcon name="gitea" size={15} /> Gitea
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link url={site.links.npm.url} blank>
|
||||||
|
<TechIcon name="npm" size={15} /> npm
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link url={site.links.linkedin.url} blank>
|
||||||
|
<BrandLinkedin size={15} /> LinkedIn
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link url={`mailto:${site.email}`}>
|
||||||
|
<Mail size={15} /> Email
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use 'styles/type' as *;
|
||||||
|
|
||||||
|
.head {
|
||||||
|
padding: var(--nds-spacing-2xl) 0 var(--nds-spacing-xl);
|
||||||
|
max-width: 48rem;
|
||||||
|
}
|
||||||
|
.head__title {
|
||||||
|
@include text-4xl;
|
||||||
|
color: var(--nds-text);
|
||||||
|
margin: var(--nds-spacing-sm) 0 var(--nds-spacing-md);
|
||||||
|
}
|
||||||
|
.head__lede {
|
||||||
|
@include text-lg;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.7fr 1fr;
|
||||||
|
gap: var(--nds-spacing-2xl);
|
||||||
|
align-items: start;
|
||||||
|
padding-bottom: var(--nds-spacing-3xl);
|
||||||
|
}
|
||||||
|
.block { margin-bottom: var(--nds-spacing-2xl); }
|
||||||
|
.prose {
|
||||||
|
@include text-base;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0 0 var(--nds-spacing-md);
|
||||||
|
max-width: 42rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline */
|
||||||
|
.timeline {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nds-spacing-md);
|
||||||
|
}
|
||||||
|
.timeline__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 130px 1fr;
|
||||||
|
gap: var(--nds-spacing-md);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.timeline__year {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--nds-accent);
|
||||||
|
padding-top: var(--nds-spacing-xs);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.timeline__card :global(.list-item) {
|
||||||
|
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||||
|
border-radius: var(--nds-radius-lg);
|
||||||
|
background: color-mix(in srgb, var(--nds-surface) 64%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
padding: var(--nds-spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--nds-spacing-2xs);
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-2xs);
|
||||||
|
padding: 4px var(--nds-spacing-xs);
|
||||||
|
border-radius: var(--nds-radius-full);
|
||||||
|
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||||
|
background: var(--nds-surface-2);
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Side panels */
|
||||||
|
.layout__side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nds-spacing-md);
|
||||||
|
position: sticky;
|
||||||
|
top: calc(64px + var(--nds-spacing-md));
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||||
|
border-radius: var(--nds-radius-lg);
|
||||||
|
background: color-mix(in srgb, var(--nds-surface) 70%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
padding: var(--nds-spacing-lg);
|
||||||
|
&--accent {
|
||||||
|
border-color: color-mix(in srgb, var(--nds-primary) 38%, var(--nds-border));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.panel__title {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0 0 var(--nds-spacing-sm);
|
||||||
|
}
|
||||||
|
.panel__big {
|
||||||
|
@include text-lg;
|
||||||
|
color: var(--nds-text);
|
||||||
|
margin: 0 0 var(--nds-spacing-sm);
|
||||||
|
}
|
||||||
|
.panel__badge { margin-bottom: var(--nds-spacing-sm); }
|
||||||
|
.panel__mobility {
|
||||||
|
@include text-sm;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0 0 var(--nds-spacing-lg);
|
||||||
|
}
|
||||||
|
.links {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nds-spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.layout { grid-template-columns: 1fr; gap: var(--nds-spacing-xl); }
|
||||||
|
.layout__side { position: static; }
|
||||||
|
}
|
||||||
|
@media (max-width: 540px) {
|
||||||
|
.timeline__item { grid-template-columns: 1fr; gap: var(--nds-spacing-2xs); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import Eyebrow from '../components/Eyebrow.astro';
|
||||||
|
import TechIcon from '../components/TechIcon.astro';
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
SelectOption,
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
} from '@unkn0wndo3s/nova-design-system';
|
||||||
|
import { Send, Mail } from '@lucide/astro';
|
||||||
|
import BrandLinkedin from '../components/BrandLinkedin.astro';
|
||||||
|
import { site } from '../data/site';
|
||||||
|
|
||||||
|
const title = 'Contact — Louis Potevin';
|
||||||
|
const description =
|
||||||
|
'Contact Louis Potevin, full-stack web developer. Available for permanent positions (CDI) starting September 2026, anywhere in France or remote.';
|
||||||
|
|
||||||
|
const directLinks = [
|
||||||
|
{ kind: 'mail', label: site.email, url: `mailto:${site.email}` },
|
||||||
|
{ kind: 'linkedin', label: 'LinkedIn', url: site.links.linkedin.url },
|
||||||
|
{ kind: 'gitea', label: 'Gitea', url: site.links.gitea.url },
|
||||||
|
{ kind: 'github', label: 'GitHub', url: site.links.github.url },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={title} description={description}>
|
||||||
|
<section class="head">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbItem href="/">Home</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem href="/contact" current>Contact</BreadcrumbItem>
|
||||||
|
</Breadcrumb>
|
||||||
|
<Eyebrow index="//" label="Connect" />
|
||||||
|
<h1 class="head__title">Get in touch</h1>
|
||||||
|
<p class="head__lede">
|
||||||
|
An opening, a project, or just a question? Email is the most direct
|
||||||
|
channel, but the form works just as well.
|
||||||
|
</p>
|
||||||
|
<div class="head__avail">
|
||||||
|
<Badge type="success" variant="soft">Available</Badge>
|
||||||
|
<span>{site.availability} · {site.mobility}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
{/* ── Form (Nova components) ── */}
|
||||||
|
<section class="formWrap" aria-labelledby="form-title">
|
||||||
|
<h2 id="form-title" class="formWrap__title">Message</h2>
|
||||||
|
|
||||||
|
<div class="form" id="contact-form">
|
||||||
|
<TextField id="cf-name" label="Name" type="text" placeholder="Your name" />
|
||||||
|
<TextField
|
||||||
|
id="cf-email"
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="form__field">
|
||||||
|
<label class="form__label" for="cf-subject">Subject</label>
|
||||||
|
<Select default="Job Opportunity (CDI)">
|
||||||
|
<SelectOption value="Job Opportunity (CDI)">
|
||||||
|
Job Opportunity (CDI)
|
||||||
|
</SelectOption>
|
||||||
|
<SelectOption value="Freelance / Contract">
|
||||||
|
Freelance / Contract
|
||||||
|
</SelectOption>
|
||||||
|
<SelectOption value="Project Inquiry">
|
||||||
|
Project Inquiry
|
||||||
|
</SelectOption>
|
||||||
|
<SelectOption value="Other">Other</SelectOption>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
id="cf-message"
|
||||||
|
label="Your message"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="A few lines about your needs…"
|
||||||
|
max={800}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="form__actions">
|
||||||
|
<Button type="primary" size="lg" htmlType="button" id="cf-send">
|
||||||
|
<Send slot="icon-left" size={16} />
|
||||||
|
Send message
|
||||||
|
</Button>
|
||||||
|
<p class="form__hint" id="cf-hint">
|
||||||
|
Opens your mail client with a pre-filled draft.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Direct Links ── */}
|
||||||
|
<aside class="direct" aria-label="Direct channels">
|
||||||
|
<p class="direct__title">Direct Channels</p>
|
||||||
|
<ul class="direct__list">
|
||||||
|
{
|
||||||
|
directLinks.map((l) => (
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="direct__item"
|
||||||
|
href={l.url}
|
||||||
|
{...(l.kind !== 'mail'
|
||||||
|
? { target: '_blank', rel: 'noopener noreferrer' }
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
|
<span class="direct__icon">
|
||||||
|
{l.kind === 'mail' && <Mail size={17} />}
|
||||||
|
{l.kind === 'linkedin' && <BrandLinkedin size={17} />}
|
||||||
|
{l.kind === 'gitea' && <TechIcon name="gitea" size={17} />}
|
||||||
|
{l.kind === 'github' && <TechIcon name="github" size={17} />}
|
||||||
|
</span>
|
||||||
|
<span class="direct__label">{l.label}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="direct__loc">
|
||||||
|
<p class="direct__locTitle">Location</p>
|
||||||
|
<p class="direct__locValue">{site.location}</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<script define:vars={{ email: site.email }}>
|
||||||
|
const send = document.getElementById('cf-send');
|
||||||
|
send?.addEventListener('click', () => {
|
||||||
|
const val = (id) => document.querySelector(`#${id} input, #${id} textarea`)?.value ?? '';
|
||||||
|
const name = val('cf-name');
|
||||||
|
const from = val('cf-email');
|
||||||
|
const subjectEl = document.querySelector('.select__label');
|
||||||
|
const subject = subjectEl?.textContent?.trim() || 'Inquiry';
|
||||||
|
const message = val('cf-message');
|
||||||
|
|
||||||
|
const body = `${message}\n\n— ${name}${from ? ` (${from})` : ''}`;
|
||||||
|
const href = `mailto:${email}?subject=${encodeURIComponent(
|
||||||
|
'[Portfolio] ' + subject,
|
||||||
|
)}&body=${encodeURIComponent(body)}`;
|
||||||
|
window.location.href = href;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use 'styles/type' as *;
|
||||||
|
|
||||||
|
.head {
|
||||||
|
padding: var(--nds-spacing-2xl) 0 var(--nds-spacing-xl);
|
||||||
|
max-width: 48rem;
|
||||||
|
}
|
||||||
|
.head__title {
|
||||||
|
@include text-4xl;
|
||||||
|
color: var(--nds-text);
|
||||||
|
margin: var(--nds-spacing-sm) 0 var(--nds-spacing-md);
|
||||||
|
}
|
||||||
|
.head__lede {
|
||||||
|
@include text-lg;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0 0 var(--nds-spacing-md);
|
||||||
|
}
|
||||||
|
.head__avail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-xs);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 1fr;
|
||||||
|
gap: var(--nds-spacing-2xl);
|
||||||
|
align-items: start;
|
||||||
|
padding-bottom: var(--nds-spacing-3xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formWrap {
|
||||||
|
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||||
|
border-radius: var(--nds-radius-xl);
|
||||||
|
background: color-mix(in srgb, var(--nds-surface) 72%, transparent);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
padding: var(--nds-spacing-xl);
|
||||||
|
}
|
||||||
|
.formWrap__title {
|
||||||
|
@include text-xl;
|
||||||
|
color: var(--nds-text);
|
||||||
|
margin: 0 0 var(--nds-spacing-lg);
|
||||||
|
}
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nds-spacing-md);
|
||||||
|
}
|
||||||
|
.form__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nds-spacing-2xs);
|
||||||
|
}
|
||||||
|
.form__label {
|
||||||
|
@include text-label;
|
||||||
|
color: var(--nds-text);
|
||||||
|
}
|
||||||
|
.form__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-md);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: var(--nds-spacing-2xs);
|
||||||
|
}
|
||||||
|
.form__hint {
|
||||||
|
@include text-sm;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direct {
|
||||||
|
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||||
|
border-radius: var(--nds-radius-xl);
|
||||||
|
background: color-mix(in srgb, var(--nds-surface) 60%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
padding: var(--nds-spacing-lg);
|
||||||
|
position: sticky;
|
||||||
|
top: calc(64px + var(--nds-spacing-md));
|
||||||
|
}
|
||||||
|
.direct__title {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0 0 var(--nds-spacing-sm);
|
||||||
|
}
|
||||||
|
.direct__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 var(--nds-spacing-lg);
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nds-spacing-2xs);
|
||||||
|
}
|
||||||
|
.direct__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-sm);
|
||||||
|
padding: var(--nds-spacing-sm);
|
||||||
|
border-radius: var(--nds-radius-md);
|
||||||
|
border: var(--nds-border-width-thin) solid transparent;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 160ms ease, color 160ms ease, background 160ms ease;
|
||||||
|
&:hover {
|
||||||
|
color: var(--nds-text);
|
||||||
|
border-color: color-mix(in srgb, var(--nds-primary) 40%, var(--nds-border));
|
||||||
|
background: var(--nds-surface-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.direct__icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--nds-primary);
|
||||||
|
}
|
||||||
|
.direct__label {
|
||||||
|
@include text-sm;
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
}
|
||||||
|
.direct__loc {
|
||||||
|
padding-top: var(--nds-spacing-md);
|
||||||
|
border-top: var(--nds-border-width-thin) solid var(--nds-border);
|
||||||
|
}
|
||||||
|
.direct__locTitle {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0 0 var(--nds-spacing-2xs);
|
||||||
|
}
|
||||||
|
.direct__locValue {
|
||||||
|
@include text-base;
|
||||||
|
color: var(--nds-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── RESPONSIVE ── */
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.layout { grid-template-columns: 1fr; gap: var(--nds-spacing-xl); }
|
||||||
|
.direct { position: static; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
---
|
||||||
|
import { Button, Badge } from '@unkn0wndo3s/nova-design-system';
|
||||||
|
import { ArrowUpRight, Send } from '@lucide/astro';
|
||||||
|
|
||||||
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
|
import Eyebrow from '../components/Eyebrow.astro';
|
||||||
|
import ProjectCard from '../components/ProjectCard.astro';
|
||||||
|
import TechIcon from '../components/TechIcon.astro';
|
||||||
|
|
||||||
|
import { site } from '../data/site';
|
||||||
|
import { featuredProjects } from '../data/projects';
|
||||||
|
|
||||||
|
const capabilities = [
|
||||||
|
{
|
||||||
|
label: 'Front-end',
|
||||||
|
body: 'Sleek and accessible interfaces built with Astro, TypeScript, and Sass — all the way to an in-house design system.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Back-end',
|
||||||
|
body: 'Server-side services and APIs, data modeling, and business logic using Node and PostgreSQL.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tools & deployment',
|
||||||
|
body: 'Docker containerization, automated CI/CD, and end-to-end production deployment.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const instruments = [
|
||||||
|
{ icon: 'astro', label: 'Astro' },
|
||||||
|
{ icon: 'typescript', label: 'TypeScript' },
|
||||||
|
{ icon: 'javascript', label: 'JavaScript' },
|
||||||
|
{ icon: 'sass', label: 'Sass' },
|
||||||
|
{ icon: 'postgresql', label: 'PostgreSQL' },
|
||||||
|
{ icon: 'docker', label: 'Docker' },
|
||||||
|
{ icon: 'linux', label: 'Linux' },
|
||||||
|
{ icon: 'cloudflare', label: 'Cloudflare' },
|
||||||
|
{ icon: 'gitea', label: 'Gitea CI/CD' },
|
||||||
|
{ icon: 'rust', label: 'Rust' },
|
||||||
|
{ icon: 'python', label: 'Python' },
|
||||||
|
{ icon: 'git', label: 'Git' },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout>
|
||||||
|
{/* ─────────────────────────── HERO ─────────────────────────── */}
|
||||||
|
<section class="hero" aria-labelledby="hero-title">
|
||||||
|
<div class="hero__status">
|
||||||
|
<span class="hero__dot" aria-hidden="true"></span>
|
||||||
|
<span class="hero__statusText">Available · {site.availability}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 id="hero-title" class="hero__title">
|
||||||
|
Louis Potevin
|
||||||
|
<span class="hero__role">Full-stack developer</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="hero__lede">
|
||||||
|
I design and develop web applications from end to end — from front-end
|
||||||
|
to back-end and production deployment. I am equally comfortable working
|
||||||
|
on the user interface as I am with the services and infrastructure running them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="hero__cta">
|
||||||
|
<Button type="primary" size="lg" href="/work">
|
||||||
|
View projects
|
||||||
|
<ArrowUpRight slot="icon-right" size={18} />
|
||||||
|
</Button>
|
||||||
|
<Button type="ghost" size="lg" href="/contact">
|
||||||
|
<Send slot="icon-left" size={16} />
|
||||||
|
Contact me
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero__avail">
|
||||||
|
<Badge type="success" variant="soft">Available</Badge>
|
||||||
|
<span class="hero__availText">{site.availability}</span>
|
||||||
|
<span class="hero__sep" aria-hidden="true">·</span>
|
||||||
|
<span class="hero__zones">{site.mobility}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─────────────────────── PROFILE / CAPABILITIES ─────────────────────── */}
|
||||||
|
<section class="band" aria-labelledby="profile-title">
|
||||||
|
<Eyebrow index="01" label="Profile" />
|
||||||
|
<div class="band__split">
|
||||||
|
<h2 id="profile-title" class="band__lead">
|
||||||
|
A full-stack profile, comfortable across the entire stack.
|
||||||
|
</h2>
|
||||||
|
<div class="band__body">
|
||||||
|
<p>
|
||||||
|
I have hands-on experience running business applications in production while
|
||||||
|
simultaneously developing my own side projects: a design system published
|
||||||
|
on npm, a self-hosted infrastructure, and tools built with Rust and Python.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
What drives me: mastering the entire cycle — the component you look at,
|
||||||
|
the service that delivers it, and the CI that deploys it. This website is
|
||||||
|
a prime example, built entirely on top of my own design system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="caps" aria-label="Expertise">
|
||||||
|
{
|
||||||
|
capabilities.map((c) => (
|
||||||
|
<li class="cap">
|
||||||
|
<span class="cap__label">{c.label}</span>
|
||||||
|
<p class="cap__body">{c.body}</p>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─────────────────────────── FEATURED PROJECTS ─────────────────────────── */}
|
||||||
|
<section class="band" aria-labelledby="proj-title">
|
||||||
|
<Eyebrow index="02" label="Featured projects" />
|
||||||
|
<div class="band__head">
|
||||||
|
<h2 id="proj-title" class="band__lead">Projects</h2>
|
||||||
|
<a class="band__all" href="/work">
|
||||||
|
All projects <ArrowUpRight size={15} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
{featuredProjects.map((p) => <ProjectCard project={p} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─────────────────────────── STACK ─────────────────────────── */}
|
||||||
|
<section class="band" aria-labelledby="stack-title">
|
||||||
|
<Eyebrow index="03" label="Tech stack" />
|
||||||
|
<h2 id="stack-title" class="band__lead band__lead--center">
|
||||||
|
My everyday tools.
|
||||||
|
</h2>
|
||||||
|
<ul class="instruments" aria-label="Core technologies">
|
||||||
|
{
|
||||||
|
instruments.map((t) => (
|
||||||
|
<li class="instruments__item">
|
||||||
|
<span class="instruments__icon">
|
||||||
|
<TechIcon name={t.icon} size={22} />
|
||||||
|
</span>
|
||||||
|
<span class="instruments__label">{t.label}</span>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ─────────────────────────── CONTACT CTA ─────────────────────────── */}
|
||||||
|
<section class="closing" aria-labelledby="cta-title">
|
||||||
|
<p class="closing__eyebrow">Available — {site.availability}</p>
|
||||||
|
<h2 id="cta-title" class="closing__title">Let's talk!</h2>
|
||||||
|
<p class="closing__text">
|
||||||
|
I am looking for a full-stack permanent position (CDI) starting September 2026.
|
||||||
|
{' '}{site.mobility}.
|
||||||
|
</p>
|
||||||
|
<div class="closing__cta">
|
||||||
|
<Button type="primary" size="lg" href="/contact">
|
||||||
|
<Send slot="icon-left" size={16} />
|
||||||
|
Contact me
|
||||||
|
</Button>
|
||||||
|
<Button type="ghost" size="lg" href="/about">
|
||||||
|
Learn more
|
||||||
|
<ArrowUpRight slot="icon-right" size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use 'styles/type' as *;
|
||||||
|
|
||||||
|
/* ── HERO ── */
|
||||||
|
.hero {
|
||||||
|
min-height: clamp(520px, 80vh, 820px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--nds-spacing-md);
|
||||||
|
padding: var(--nds-spacing-3xl) 0 var(--nds-spacing-2xl);
|
||||||
|
max-width: 56rem;
|
||||||
|
}
|
||||||
|
.hero__status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-xs);
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: var(--nds-spacing-2xs) var(--nds-spacing-sm);
|
||||||
|
border: var(--nds-border-width-thin) solid var(--nds-border-strong);
|
||||||
|
border-radius: var(--nds-radius-full);
|
||||||
|
background: color-mix(in srgb, var(--nds-surface) 60%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
.hero__dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: var(--nds-radius-full);
|
||||||
|
background: var(--nds-primary);
|
||||||
|
box-shadow: 0 0 10px var(--nds-primary);
|
||||||
|
animation: pulse 2.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.hero__statusText {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
}
|
||||||
|
.hero__title {
|
||||||
|
@include text-5xl;
|
||||||
|
margin: var(--nds-spacing-sm) 0 0;
|
||||||
|
color: var(--nds-text);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nds-spacing-2xs);
|
||||||
|
}
|
||||||
|
.hero__role {
|
||||||
|
@include text-2xl;
|
||||||
|
color: var(--nds-primary);
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.hero__lede {
|
||||||
|
@include text-lg;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
max-width: 44rem;
|
||||||
|
margin: var(--nds-spacing-xs) 0 0;
|
||||||
|
}
|
||||||
|
.hero__cta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--nds-spacing-sm);
|
||||||
|
margin-top: var(--nds-spacing-md);
|
||||||
|
}
|
||||||
|
.hero__avail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--nds-spacing-xs);
|
||||||
|
margin-top: var(--nds-spacing-sm);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
}
|
||||||
|
.hero__availText { color: var(--nds-text); }
|
||||||
|
.hero__sep { color: var(--nds-border-strong); }
|
||||||
|
.hero__zones {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── SECTION BANDS ── */
|
||||||
|
.band { padding: var(--nds-spacing-2xl) 0; }
|
||||||
|
.band__lead {
|
||||||
|
@include text-3xl;
|
||||||
|
color: var(--nds-text);
|
||||||
|
margin: 0;
|
||||||
|
max-width: 32rem;
|
||||||
|
&--center {
|
||||||
|
max-width: none;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: var(--nds-spacing-2xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.band__split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.2fr;
|
||||||
|
gap: var(--nds-spacing-2xl);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.band__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nds-spacing-md);
|
||||||
|
p {
|
||||||
|
@include text-base;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.band__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--nds-spacing-md);
|
||||||
|
margin-bottom: var(--nds-spacing-xl);
|
||||||
|
}
|
||||||
|
.band__all {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-2xs);
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--nds-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
&:hover { color: var(--nds-accent); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Capabilities ── */
|
||||||
|
.caps {
|
||||||
|
list-style: none;
|
||||||
|
margin: var(--nds-spacing-2xl) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--nds-spacing-lg);
|
||||||
|
}
|
||||||
|
.cap {
|
||||||
|
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||||
|
border-radius: var(--nds-radius-lg);
|
||||||
|
background: color-mix(in srgb, var(--nds-surface) 64%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
padding: var(--nds-spacing-lg);
|
||||||
|
}
|
||||||
|
.cap__label {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--nds-primary);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.cap__body {
|
||||||
|
@include text-sm;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: var(--nds-spacing-xs) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--nds-spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── INSTRUMENTS ── */
|
||||||
|
.instruments {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: var(--nds-spacing-sm);
|
||||||
|
}
|
||||||
|
.instruments__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-xs);
|
||||||
|
padding: var(--nds-spacing-lg) var(--nds-spacing-sm);
|
||||||
|
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||||
|
border-radius: var(--nds-radius-lg);
|
||||||
|
background: color-mix(in srgb, var(--nds-surface) 64%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
transition: border-color 180ms ease, transform 180ms ease, color 180ms ease;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
&:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--nds-primary) 50%, var(--nds-border));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
color: var(--nds-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.instruments__icon { display: grid; place-items: center; color: inherit; }
|
||||||
|
.instruments__label {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CLOSING ── */
|
||||||
|
.closing {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--nds-spacing-3xl) 0;
|
||||||
|
max-width: 40rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.closing__eyebrow {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
color: var(--nds-accent);
|
||||||
|
margin: 0 0 var(--nds-spacing-md);
|
||||||
|
}
|
||||||
|
.closing__title {
|
||||||
|
@include text-4xl;
|
||||||
|
color: var(--nds-text);
|
||||||
|
margin: 0 0 var(--nds-spacing-md);
|
||||||
|
}
|
||||||
|
.closing__text {
|
||||||
|
@include text-lg;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0 0 var(--nds-spacing-xl);
|
||||||
|
}
|
||||||
|
.closing__cta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--nds-spacing-sm);
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hero__dot { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── RESPONSIVE ── */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.band__split { grid-template-columns: 1fr; gap: var(--nds-spacing-lg); }
|
||||||
|
.caps { grid-template-columns: 1fr; }
|
||||||
|
.grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.instruments { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.instruments { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
.hero__cta :global(.button) { width: 100%; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import Eyebrow from '../../components/Eyebrow.astro';
|
||||||
|
import TechIcon from '../../components/TechIcon.astro';
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Link,
|
||||||
|
} from '@unkn0wndo3s/nova-design-system';
|
||||||
|
import { ArrowUpRight, ArrowLeft } from '@lucide/astro';
|
||||||
|
import { projects, statusMeta, type Project } from '../../data/projects';
|
||||||
|
|
||||||
|
export function getStaticPaths() {
|
||||||
|
return projects.map((project) => ({
|
||||||
|
params: { slug: project.slug },
|
||||||
|
props: { project },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project;
|
||||||
|
}
|
||||||
|
const { project } = Astro.props;
|
||||||
|
const status = statusMeta[project.status];
|
||||||
|
const title = `${project.title} — Louis Potevin`;
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={title} description={project.tagline}>
|
||||||
|
<article class="detail">
|
||||||
|
<header class="detail__head">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbItem href="/">Home</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem href="/work">Projects</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem href={`/work/${project.slug}`} current>
|
||||||
|
{project.designation}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
|
<div class="detail__meta">
|
||||||
|
<span class="detail__code">{project.designation}</span>
|
||||||
|
<Badge type={status.tone} variant="soft">{status.label}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="detail__title">{project.title}</h1>
|
||||||
|
<p class="detail__tagline">{project.tagline}</p>
|
||||||
|
|
||||||
|
<dl class="detail__facts">
|
||||||
|
<div class="fact">
|
||||||
|
<dt>Role</dt>
|
||||||
|
<dd>{project.role}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="fact">
|
||||||
|
<dt>Timeline</dt>
|
||||||
|
<dd>{project.period}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="detail__grid">
|
||||||
|
<div class="detail__main">
|
||||||
|
<p class="detail__summary">{project.summary}</p>
|
||||||
|
|
||||||
|
<section class="block" aria-labelledby="ctx">
|
||||||
|
<Eyebrow index="01" label="Context" />
|
||||||
|
<ul class="ticks">
|
||||||
|
{project.context.map((c) => <li>{c}</li>)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block" aria-labelledby="contrib">
|
||||||
|
<Eyebrow index="02" label="Contributions" />
|
||||||
|
<ul class="ticks">
|
||||||
|
{project.contributions.map((c) => <li>{c}</li>)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="detail__side">
|
||||||
|
<div class="panel">
|
||||||
|
<p class="panel__title">Stack</p>
|
||||||
|
<ul class="chips">
|
||||||
|
{
|
||||||
|
project.stack.map((s) => (
|
||||||
|
<li class="chip">
|
||||||
|
<TechIcon name={s.icon} size={15} />
|
||||||
|
{s.label}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
project.links.length > 0 && (
|
||||||
|
<div class="panel">
|
||||||
|
<p class="panel__title">Links</p>
|
||||||
|
<ul class="links">
|
||||||
|
{project.links.map((l) => (
|
||||||
|
<li>
|
||||||
|
<Link url={l.url} blank>
|
||||||
|
{l.icon && <TechIcon name={l.icon} size={15} />}
|
||||||
|
{l.label}
|
||||||
|
<ArrowUpRight size={13} />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
project.links.length === 0 && (
|
||||||
|
<div class="panel panel--muted">
|
||||||
|
<p class="panel__note">
|
||||||
|
Internal or private project — no public link available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="detail__foot">
|
||||||
|
<Button type="ghost" href="/work">
|
||||||
|
<ArrowLeft slot="icon-left" size={16} />
|
||||||
|
All projects
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" href="/contact">
|
||||||
|
Discuss this project
|
||||||
|
<ArrowUpRight slot="icon-right" size={16} />
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use 'styles/type' as *;
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
padding: var(--nds-spacing-2xl) 0 var(--nds-spacing-3xl);
|
||||||
|
max-width: 64rem;
|
||||||
|
}
|
||||||
|
.detail__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-sm);
|
||||||
|
margin: var(--nds-spacing-lg) 0 var(--nds-spacing-sm);
|
||||||
|
}
|
||||||
|
.detail__code {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: var(--nds-accent);
|
||||||
|
}
|
||||||
|
.detail__title {
|
||||||
|
@include text-4xl;
|
||||||
|
color: var(--nds-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.detail__tagline {
|
||||||
|
@include text-lg;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: var(--nds-spacing-sm) 0 0;
|
||||||
|
}
|
||||||
|
.detail__facts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--nds-spacing-2xl);
|
||||||
|
margin: var(--nds-spacing-lg) 0 0;
|
||||||
|
padding-top: var(--nds-spacing-md);
|
||||||
|
border-top: var(--nds-border-width-thin) solid var(--nds-border);
|
||||||
|
}
|
||||||
|
.fact {
|
||||||
|
margin: 0;
|
||||||
|
dt {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
@include text-base;
|
||||||
|
color: var(--nds-text);
|
||||||
|
margin: var(--nds-spacing-3xs) 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.6fr 1fr;
|
||||||
|
gap: var(--nds-spacing-2xl);
|
||||||
|
margin-top: var(--nds-spacing-2xl);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.detail__summary {
|
||||||
|
@include text-lg;
|
||||||
|
color: var(--nds-text);
|
||||||
|
margin: 0 0 var(--nds-spacing-xl);
|
||||||
|
}
|
||||||
|
.block {
|
||||||
|
margin-bottom: var(--nds-spacing-xl);
|
||||||
|
}
|
||||||
|
.ticks {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nds-spacing-sm);
|
||||||
|
li {
|
||||||
|
position: relative;
|
||||||
|
padding-left: var(--nds-spacing-md);
|
||||||
|
@include text-base;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0.55em;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
background: var(--nds-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__side {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nds-spacing-md);
|
||||||
|
position: sticky;
|
||||||
|
top: calc(64px + var(--nds-spacing-md));
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||||
|
border-radius: var(--nds-radius-lg);
|
||||||
|
background: color-mix(in srgb, var(--nds-surface) 70%, transparent);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
padding: var(--nds-spacing-lg);
|
||||||
|
&--muted { background: color-mix(in srgb, var(--nds-surface) 40%, transparent); }
|
||||||
|
}
|
||||||
|
.panel__title {
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0 0 var(--nds-spacing-sm);
|
||||||
|
}
|
||||||
|
.panel__note {
|
||||||
|
@include text-sm;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--nds-spacing-2xs);
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--nds-spacing-2xs);
|
||||||
|
padding: 4px var(--nds-spacing-xs);
|
||||||
|
border-radius: var(--nds-radius-full);
|
||||||
|
border: var(--nds-border-width-thin) solid var(--nds-border);
|
||||||
|
background: var(--nds-surface-2);
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
font-family: var(--nds-font-mono);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
.links {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--nds-spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail__foot {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--nds-spacing-sm);
|
||||||
|
margin-top: var(--nds-spacing-2xl);
|
||||||
|
padding-top: var(--nds-spacing-lg);
|
||||||
|
border-top: var(--nds-border-width-thin) solid var(--nds-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── RESPONSIVE ── */
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.detail__grid { grid-template-columns: 1fr; gap: var(--nds-spacing-xl); }
|
||||||
|
.detail__side { position: static; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
|
import Eyebrow from '../../components/Eyebrow.astro';
|
||||||
|
import ProjectCard from '../../components/ProjectCard.astro';
|
||||||
|
import { Breadcrumb, BreadcrumbItem } from '@unkn0wndo3s/nova-design-system';
|
||||||
|
import { projects } from '../../data/projects';
|
||||||
|
|
||||||
|
const title = 'Projects — Louis Potevin, full-stack web developer / développeur full-stack';
|
||||||
|
const description =
|
||||||
|
'Projects by Louis Potevin: Astro design system, self-hosted infrastructure, systems tools in Rust, and LLM explorations. Full-stack web developer.';
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout title={title} description={description}>
|
||||||
|
<section class="head">
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbItem href="/">Home</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem href="/work" current>Projects</BreadcrumbItem>
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
|
<Eyebrow index="//" label="All projects" />
|
||||||
|
<h1 class="head__title">Projects</h1>
|
||||||
|
<p class="head__lede">
|
||||||
|
A showcase of everything I build — from business software running in production to
|
||||||
|
the infrastructure supporting it, alongside my personal tools.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid" aria-label="Projects list">
|
||||||
|
{projects.map((p) => <ProjectCard project={p} />)}
|
||||||
|
</section>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use 'styles/type' as *;
|
||||||
|
|
||||||
|
.head {
|
||||||
|
padding: var(--nds-spacing-2xl) 0 var(--nds-spacing-xl);
|
||||||
|
max-width: 48rem;
|
||||||
|
}
|
||||||
|
.head__title {
|
||||||
|
@include text-4xl;
|
||||||
|
color: var(--nds-text);
|
||||||
|
margin: var(--nds-spacing-sm) 0 var(--nds-spacing-md);
|
||||||
|
}
|
||||||
|
.head__lede {
|
||||||
|
@include text-lg;
|
||||||
|
color: var(--nds-neutral);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--nds-spacing-lg);
|
||||||
|
padding-bottom: var(--nds-spacing-3xl);
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Re-exports the official typography mixins from Nova DS.
|
||||||
|
// The partial is resolved via a loadPath pointing to the package's
|
||||||
|
// style folder (its `exports` field prevents direct access to the subpath).
|
||||||
|
// Usage in a component: @use "styles/type" as *; then @include text-3xl;
|
||||||
|
@forward "tokens/typography";
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
@use 'styles/type' as *;
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--nds-text);
|
||||||
|
font-family: var(--nds-font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
background-color: var(--nds-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: color-mix(in srgb, var(--nds-primary) 35%, transparent);
|
||||||
|
color: var(--nds-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--nds-border-strong) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict",
|
||||||
|
"include": [".astro/types.d.ts", "**/*"],
|
||||||
|
"exclude": ["dist"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["three"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user