Compare commits
2 Commits
f2a155c947
...
fa37f0358d
| Author | SHA1 | Date | |
|---|---|---|---|
| fa37f0358d | |||
| 15bc86b5a6 |
+14
-135
@@ -1,138 +1,17 @@
|
||||
# ---> Node
|
||||
# Logs
|
||||
logs
|
||||
# build
|
||||
dist/
|
||||
.astro/
|
||||
|
||||
# deps
|
||||
node_modules/
|
||||
|
||||
# env & logs
|
||||
.env
|
||||
.env.*
|
||||
*.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