From fa37f0358db87259d226f4e38138ed352eb4f462 Mon Sep 17 00:00:00 2001 From: LOUIS POTEVIN Date: Wed, 24 Jun 2026 14:21:30 +0200 Subject: [PATCH] feat: add contact, index, and work pages with responsive design and functionality - Implemented a contact page with a form for inquiries and direct links to social profiles. - Created an index page showcasing personal profile, capabilities, featured projects, and tech stack. - Developed dynamic project detail pages with breadcrumb navigation and project-specific information. - Added a projects overview page displaying all projects in a grid layout. - Introduced global styles and typography mixins for consistent design across components. --- README.md | 106 ++++++- public/favicon.svg | 12 + public/og.png | Bin 0 -> 40988 bytes public/robots.txt | 4 + src/components/BrandLinkedin.astro | 23 ++ src/components/Eyebrow.astro | 39 +++ src/components/ProjectCard.astro | 104 +++++++ src/components/SiteFooter.astro | 134 +++++++++ src/components/SiteNav.astro | 57 ++++ src/components/SpaceBackground.astro | 51 ++++ src/components/TechIcon.astro | 52 ++++ src/components/scene/scene.ts | 250 ++++++++++++++++ src/data/projects.ts | 179 ++++++++++++ src/data/site.ts | 37 +++ src/layouts/BaseLayout.astro | 169 +++++++++++ src/pages/about.astro | 315 ++++++++++++++++++++ src/pages/contact.astro | 297 +++++++++++++++++++ src/pages/index.astro | 418 +++++++++++++++++++++++++++ src/pages/work/[slug].astro | 305 +++++++++++++++++++ src/pages/work/index.astro | 62 ++++ src/styles/_type.scss | 5 + src/styles/global.scss | 44 +++ 22 files changed, 2662 insertions(+), 1 deletion(-) create mode 100644 public/favicon.svg create mode 100644 public/og.png create mode 100644 public/robots.txt create mode 100644 src/components/BrandLinkedin.astro create mode 100644 src/components/Eyebrow.astro create mode 100644 src/components/ProjectCard.astro create mode 100644 src/components/SiteFooter.astro create mode 100644 src/components/SiteNav.astro create mode 100644 src/components/SpaceBackground.astro create mode 100644 src/components/TechIcon.astro create mode 100644 src/components/scene/scene.ts create mode 100644 src/data/projects.ts create mode 100644 src/data/site.ts create mode 100644 src/layouts/BaseLayout.astro create mode 100644 src/pages/about.astro create mode 100644 src/pages/contact.astro create mode 100644 src/pages/index.astro create mode 100644 src/pages/work/[slug].astro create mode 100644 src/pages/work/index.astro create mode 100644 src/styles/_type.scss create mode 100644 src/styles/global.scss diff --git a/README.md b/README.md index daf1295..c2f90dc 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..b23b416 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/og.png b/public/og.png new file mode 100644 index 0000000000000000000000000000000000000000..12f78961946ce5796a8f65c2b8b38c5d8b8506a4 GIT binary patch literal 40988 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 literal 0 HcmV?d00001 diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..fb21f4b --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://louis-potevin.dev/sitemap-index.xml diff --git a/src/components/BrandLinkedin.astro b/src/components/BrandLinkedin.astro new file mode 100644 index 0000000..af66c75 --- /dev/null +++ b/src/components/BrandLinkedin.astro @@ -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; +--- + + + + \ No newline at end of file diff --git a/src/components/Eyebrow.astro b/src/components/Eyebrow.astro new file mode 100644 index 0000000..a0d5db1 --- /dev/null +++ b/src/components/Eyebrow.astro @@ -0,0 +1,39 @@ +--- +export interface Props { + index?: string; // ex. "01" + label: string; +} +const { index, label } = Astro.props; +--- + +

+ {index && {index}} + + {label} +

+ + diff --git a/src/components/ProjectCard.astro b/src/components/ProjectCard.astro new file mode 100644 index 0000000..d49981f --- /dev/null +++ b/src/components/ProjectCard.astro @@ -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}`; +--- + +
+ +
+ {project.designation} + {status.label} +
+

{project.summary}

+
+ { + project.stack.map((s) => ( + + + {s.label} + + )) + } +
+ +
+
+ + diff --git a/src/components/SiteFooter.astro b/src/components/SiteFooter.astro new file mode 100644 index 0000000..8b5994b --- /dev/null +++ b/src/components/SiteFooter.astro @@ -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 }, +]; +--- + + + + diff --git a/src/components/SiteNav.astro b/src/components/SiteNav.astro new file mode 100644 index 0000000..4d29cc1 --- /dev/null +++ b/src/components/SiteNav.astro @@ -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 + '/'); +--- + + + + diff --git a/src/components/SpaceBackground.astro b/src/components/SpaceBackground.astro new file mode 100644 index 0000000..eb9a2b3 --- /dev/null +++ b/src/components/SpaceBackground.astro @@ -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). +--- + + + + + + \ No newline at end of file diff --git a/src/components/TechIcon.astro b/src/components/TechIcon.astro new file mode 100644 index 0000000..b63af57 --- /dev/null +++ b/src/components/TechIcon.astro @@ -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 = { + 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 && } \ No newline at end of file diff --git a/src/components/scene/scene.ts b/src/components/scene/scene.ts new file mode 100644 index 0000000..5e16c95 --- /dev/null +++ b/src/components/scene/scene.ts @@ -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(); + }); + }, + }; +} \ No newline at end of file diff --git a/src/data/projects.ts b/src/data/projects.ts new file mode 100644 index 0000000..f370495 --- /dev/null +++ b/src/data/projects.ts @@ -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 = { + live: { label: 'Live', tone: 'success' }, + maintenu: { label: 'Maintained', tone: 'primary' }, + 'r&d': { label: 'In Progress', tone: 'warning' }, + archive: { label: 'Archived', tone: 'neutral' }, +}; \ No newline at end of file diff --git a/src/data/site.ts b/src/data/site.ts new file mode 100644 index 0000000..2dd522a --- /dev/null +++ b/src/data/site.ts @@ -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 }; diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..f265fde --- /dev/null +++ b/src/layouts/BaseLayout.astro @@ -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'], +}; +--- + + + + + + + + + + {title} + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..5d5ae3f --- /dev/null +++ b/src/pages/index.astro @@ -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' }, +]; +--- + + + {/* ─────────────────────────── HERO ─────────────────────────── */} +
+
+ + Available · {site.availability} +
+ +

+ Louis Potevin + Full-stack developer +

+ +

+ 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. +

+ +
+ + +
+ +
+ Available + {site.availability} + + {site.mobility} +
+
+ + {/* ─────────────────────── PROFILE / CAPABILITIES ─────────────────────── */} +
+ +
+

+ A full-stack profile, comfortable across the entire stack. +

+
+

+ 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. +

+

+ 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. +

+
+
+ +
    + { + capabilities.map((c) => ( +
  • + {c.label} +

    {c.body}

    +
  • + )) + } +
+
+ + {/* ─────────────────────────── FEATURED PROJECTS ─────────────────────────── */} +
+ +
+

Projects

+ + All projects + +
+
+ {featuredProjects.map((p) => )} +
+
+ + {/* ─────────────────────────── STACK ─────────────────────────── */} +
+ +

+ My everyday tools. +

+
    + { + instruments.map((t) => ( +
  • + + + + {t.label} +
  • + )) + } +
+
+ + {/* ─────────────────────────── CONTACT CTA ─────────────────────────── */} +
+

Available — {site.availability}

+

Let's talk!

+

+ I am looking for a full-stack permanent position (CDI) starting September 2026. + {' '}{site.mobility}. +

+
+ + +
+
+
+ + \ No newline at end of file diff --git a/src/pages/work/[slug].astro b/src/pages/work/[slug].astro new file mode 100644 index 0000000..a3687b0 --- /dev/null +++ b/src/pages/work/[slug].astro @@ -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`; +--- + + +
+
+ + Home + Projects + + {project.designation} + + + +
+ {project.designation} + {status.label} +
+ +

{project.title}

+

{project.tagline}

+ +
+
+
Role
+
{project.role}
+
+
+
Timeline
+
{project.period}
+
+
+
+ +
+
+

{project.summary}

+ +
+ +
    + {project.context.map((c) =>
  • {c}
  • )} +
+
+ +
+ +
    + {project.contributions.map((c) =>
  • {c}
  • )} +
+
+
+ + +
+ +
+ + +
+
+
+ + \ No newline at end of file diff --git a/src/pages/work/index.astro b/src/pages/work/index.astro new file mode 100644 index 0000000..3e5168a --- /dev/null +++ b/src/pages/work/index.astro @@ -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.'; +--- + + +
+ + Home + Projects + + + +

Projects

+

+ A showcase of everything I build — from business software running in production to + the infrastructure supporting it, alongside my personal tools. +

+
+ +
+ {projects.map((p) => )} +
+
+ + \ No newline at end of file diff --git a/src/styles/_type.scss b/src/styles/_type.scss new file mode 100644 index 0000000..643b86c --- /dev/null +++ b/src/styles/_type.scss @@ -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"; diff --git a/src/styles/global.scss b/src/styles/global.scss new file mode 100644 index 0000000..b471d99 --- /dev/null +++ b/src/styles/global.scss @@ -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; +}