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.
This commit is contained in:
LOUIS POTEVIN
2026-06-24 14:21:30 +02:00
parent 15bc86b5a6
commit fa37f0358d
22 changed files with 2662 additions and 1 deletions
+23
View File
@@ -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>
+39
View File
@@ -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>
+104
View File
@@ -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>
+134
View File
@@ -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>
+57
View File
@@ -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>
+51
View File
@@ -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>
+52
View File
@@ -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" />}
+250
View File
@@ -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();
});
},
};
}