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 0000000..12f7896 Binary files /dev/null and b/public/og.png differ 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; +}