Compare commits

..

9 Commits

Author SHA1 Message Date
Johan LEROY
43e2640cd7 update projets add kys
All checks were successful
Deploy production / build-deploy (push) Successful in 35s
2026-07-21 11:32:44 +02:00
Johan LEROY
3504c3d219 update info
All checks were successful
Deploy production / build-deploy (push) Successful in 26s
2026-07-03 09:42:24 +02:00
Johan LEROY
155ab2977f feat(stack): grille complète (71 technos) en masonry centré + peaufinage UI
All checks were successful
Deploy production / build-deploy (push) Successful in 24s
Stack & icônes
- grille unique enrichie (+24 technos) : React, TypeORM, Apache, Gitea,
  GitLab, GitHub Actions, Vite, pnpm, Socket.io, Chart.js, Discord.js,
  Prisma, Sequelize, IntelliJ IDEA, VS Code, Windows, HubSpot, HTML5/CSS3…
- nouvelles catégories « Bases de données & ORM » et « Tests & Qualité »
- 21 icônes simple-icons + tracés monochromes custom VS Code, Windows,
  Playwright ; support du fill-rule dans TechIcon
- mise en page masonry (CSS columns) + titres et puces centrés

Contenu & UI
- Hero : retrait du badge « ouvert aux opportunités »
- About / Contact : reformulation orientée projets
- ProjectCard : statut « en développement »
- typographie : tirets cadratins → virgules (titres, OG, aria-labels)
- ajout du visuel public/images/logo/couverture.png
2026-06-27 13:46:53 +02:00
Johan LEROY
8b2d823fdf feat(social): ajoute le lien SoundCloud (Hero, Contact, SEO) + lien site Arboretum
All checks were successful
Deploy production / build-deploy (push) Successful in 24s
2026-06-25 07:23:22 +02:00
Johan LEROY
bd569658d1 refonte UI: navbar épurée, projets en liste unique, logo Gitea
All checks were successful
Deploy production / build-deploy (push) Successful in 25s
- header: retrait du bouton GitHub (desktop + mobile), reste dans Hero/Contact
- hero: bouton « Télécharger mon CV » désactivé, « Voir mes projets » promu en CTA principal
- projets: fusion phares/autres en une seule grille (ordre récent → ancien)
- contact: retrait du lien « site » (pointait vers le site courant)
- icônes: vrai logo Gitea (tasse) à la place du git-branch générique
- nettoyage du CSS/code mort (.is-other, .m-github, icône site)
2026-06-23 11:49:23 +02:00
Johan LEROY
c80783dfc4 qualiobee
All checks were successful
Deploy production / build-deploy (push) Successful in 26s
2026-06-22 11:17:13 +02:00
Johan LEROY
c7279d052e feat(projets): lien Amarea Tattoo (amarea-tattoo.fr) → carte cliquable
All checks were successful
Deploy production / build-deploy (push) Successful in 23s
2026-06-22 10:04:19 +02:00
Johan LEROY
a30d26aad4 feat(projets): cartes entièrement cliquables + logos adaptatifs clair/sombre
All checks were successful
Deploy production / build-deploy (push) Successful in 30s
- ProjectCard : carte = <a> (nouvel onglet) dès qu'un lien est défini,
  focus clavier accessible, hover de l'icône piloté par la carte
- Logos en object-fit:contain sur fond --surface adaptatif (cadrage « logo entier »)
- Nouveau champ Project.imgDark + swap CSS via [data-theme] (logo blanc en sombre)
- Liens : LPLV (live, libertypourlavie.fr), KSauce (ksauce.lidge.fr), Lidge (lidge.fr)
- Lidge : description sans Jellyfin/Mail → Cloud, Vault, Git
- Logos ajoutés : Amarea, Lidge, Arboretum (clair/sombre), KSauce (+ silhouette blanche générée), Techos (paire light/dark)
2026-06-22 10:01:36 +02:00
Johan LEROY
699353910f ci: corrige le déploiement FTPS (CN mismatch → ssl:check-hostname no)
All checks were successful
Deploy production / build-deploy (push) Successful in 1m48s
Cert Plesk valide (CA) mais CN != FTP_HOST. On conserve le chiffrement forcé
et la validation de chaîne, on tolère le nom d'hôte (choix validé par l'utilisateur).
2026-06-19 16:09:16 +02:00
27 changed files with 1905 additions and 1529 deletions

View File

@@ -32,13 +32,16 @@ jobs:
- name: Deploy via FTPS (Plesk)
run: |
apt-get update && apt-get install -y lftp
# FTPS forcé (jamais de fallback en clair) + vérification du certificat (CA système).
# Si le déploiement échoue sur une erreur de certificat, c'est que le cert FTP ne
# correspond pas à FTP_HOST → préférer SFTP (clé SSH) plutôt que de désactiver la vérif.
# FTPS forcé (jamais de transport en clair) + validation du certificat par la CA.
# ssl:check-hostname no : le cert Plesk est valide (signé CA) mais son CN ne correspond
# pas à FTP_HOST (alias/IP) → on garde la validation de chaîne, on tolère le nom d'hôte.
# Bien plus sûr que `verify-certificate no` (qui accepterait n'importe quel cert).
# Alternative sans compromis : SFTP par clé SSH (sftp://) — à câbler si souhaité.
lftp -e "
set ftp:ssl-force true;
set ftp:ssl-protect-data true;
set ssl:verify-certificate yes;
set ssl:check-hostname no;
open ${{ secrets.FTP_HOST }};
user ${{ secrets.FTP_USER }} ${{ secrets.FTP_PASSWORD }};
mirror --reverse --delete --verbose dist/ ${{ secrets.FTP_TARGET_DIR }};

2917
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,11 +13,11 @@
"astro": "astro"
},
"dependencies": {
"astro": "^6.1.8",
"astro": "^7.1.3",
"simple-icons": "^13.16.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.8",
"@astrojs/check": "^0.9.9",
"typescript": "^5.9.3"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -14,7 +14,7 @@ import { profile, stats } from "../data/content";
</div>
<div>
<div class="eyebrow">01 / à propos</div>
<h2 class="h2 about-h2">Un développeur qui livre, du code à la prod.</h2>
<h2 class="h2 about-h2">Penser, construire, livrer.</h2>
<p class="about-p">{profile.bio}</p>
</div>
</div>

View File

@@ -5,9 +5,9 @@ import { social, coffeeUrl } from "../data/content";
<section class="section container" id="contact">
<div class="reveal contact-card">
<div class="eyebrow">06 / contact</div>
<h2 class="contact-h2">Une idée, un poste, un projet ? Parlons-en.</h2>
<h2 class="contact-h2">Une idée à construire ensemble ? Parlons-en.</h2>
<p class="contact-p">
Disponible pour de nouvelles opportunités. Le plus simple, c'est un email — je réponds vite.
Toujours partant pour échanger autour d'un projet. Le plus simple, c'est un email. Je réponds vite.
</p>
<div class="contact-cta">
@@ -20,7 +20,7 @@ import { social, coffeeUrl } from "../data/content";
<a class="social-link" href={social.github} target="_blank" rel="noopener" aria-label="GitHub"><Icon name="github" /></a>
<a class="social-link" href={social.gitea} target="_blank" rel="noopener" aria-label="Gitea"><Icon name="gitea" /></a>
<a class="social-link" href={social.linkedin} target="_blank" rel="noopener" aria-label="LinkedIn"><Icon name="linkedin" /></a>
<a class="social-link" href={social.site} target="_blank" rel="noopener" aria-label="Site"><Icon name="site" /></a>
<a class="social-link" href={social.soundcloud} target="_blank" rel="noopener" aria-label="SoundCloud"><Icon name="soundcloud" /></a>
</div>
<div class="echo"><b>$</b> echo "écris-moi"</div>

View File

@@ -1,6 +1,6 @@
---
import Icon from "./Icon.astro";
import { social, coffeeUrl } from "../data/content";
import { coffeeUrl } from "../data/content";
const nav = [
{ label: "À propos", href: "#about", id: "about" },
@@ -12,7 +12,7 @@ const nav = [
---
<header class="site-header">
<nav class="nav" aria-label="Navigation principale">
<a class="nav-logo" href="#top" aria-label="Johan Leroy accueil">
<a class="nav-logo" href="#top" aria-label="Johan Leroy, accueil">
<img src="/images/logo/logo.png" alt="Logo Johan Leroy" width="36" height="29" />
</a>
@@ -36,16 +36,6 @@ const nav = [
<Icon name="moon" class="icon-moon" />
</button>
<a
class="btn-icon btn-github--header"
href={social.github}
target="_blank"
rel="noopener"
aria-label="GitHub (ouvre un nouvel onglet)"
>
<Icon name="github" />
</a>
<button
id="burger"
type="button"
@@ -62,7 +52,6 @@ const nav = [
<div id="mobile-menu" class="mobile-menu">
{nav.map((i) => <a class="mobile-link" href={i.href}>{i.label}</a>)}
<div class="mobile-cta">
<a class="m-github" href={social.github} target="_blank" rel="noopener">GitHub</a>
<a
class="m-coffee"
href={coffeeUrl}

View File

@@ -5,16 +5,17 @@ import { profile, social, terminalText } from "../data/content";
<section class="section container hero">
<div class="hero-grid">
<div>
<span class="badge"><span class="badge-dot"></span>Ouvert aux opportunités</span>
<h1>{profile.name}</h1>
<p class="hero-role">{profile.role}</p>
<p class="hero-lede">{profile.lede}</p>
<div class="cta-row">
{/* Bouton CV désactivé temporairement — réactiver en décommentant :
<a class="btn btn-primary" href="/cv.pdf" download>
<Icon name="download" size={16} /> Télécharger mon CV
</a>
<a class="btn btn-secondary" href="#projets">
*/}
<a class="btn btn-primary" href="#projets">
Voir mes projets <Icon name="arrow" size={16} />
</a>
</div>
@@ -23,6 +24,7 @@ import { profile, social, terminalText } from "../data/content";
<a class="social-link" href={social.github} target="_blank" rel="noopener" aria-label="GitHub"><Icon name="github" /></a>
<a class="social-link" href={social.gitea} target="_blank" rel="noopener" aria-label="Gitea auto-hébergé"><Icon name="gitea" /></a>
<a class="social-link" href={social.linkedin} target="_blank" rel="noopener" aria-label="LinkedIn"><Icon name="linkedin" /></a>
<a class="social-link" href={social.soundcloud} target="_blank" rel="noopener" aria-label="SoundCloud"><Icon name="soundcloud" /></a>
<a class="social-link" href={`mailto:${social.email}`} aria-label="Envoyer un email"><Icon name="mail" /></a>
</div>
</div>

View File

@@ -3,7 +3,7 @@
pleins (github/linkedin), stroke pour le reste (style lucide). */
interface Props {
name:
| "github" | "gitea" | "linkedin" | "mail" | "site"
| "github" | "gitea" | "linkedin" | "soundcloud" | "mail"
| "arrow" | "download" | "external" | "coffee"
| "sun" | "moon" | "burger"
| "gamepad" | "film" | "music" | "code";
@@ -12,17 +12,17 @@ interface Props {
}
const { name, size = 18, class: className } = Astro.props;
const FILL = new Set(["github", "linkedin"]);
const FILL = new Set(["github", "linkedin", "gitea", "soundcloud"]);
const STROKE_WIDTH: Record<string, number> = {
arrow: 2, external: 2, burger: 2, globe: 1.7, download: 1.9, site: 1.7,
arrow: 2, external: 2, burger: 2, globe: 1.7, download: 1.9,
};
const PATHS: Record<string, string> = {
github: `<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23a11.5 11.5 0 0 1 3.003-.404c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222 0 1.606-.014 2.898-.014 3.293 0 .322.216.694.825.576C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>`,
linkedin: `<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>`,
gitea: `<line x1="6" y1="3" x2="6" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>`,
gitea: `<path d="M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"/>`,
soundcloud: `<path d="M23.999 14.165c-.052 1.796-1.612 3.169-3.4 3.169h-8.18a.68.68 0 0 1-.675-.683V7.862a.747.747 0 0 1 .452-.724s.75-.513 2.333-.513a5.364 5.364 0 0 1 2.763.755 5.433 5.433 0 0 1 2.57 3.54c.282-.08.574-.121.868-.12.884 0 1.73.358 2.347.992s.948 1.49.922 2.373ZM10.721 8.421c.247 2.98.427 5.697 0 8.672a.264.264 0 0 1-.53 0c-.395-2.946-.22-5.718 0-8.672a.264.264 0 0 1 .53 0ZM9.072 9.448c.285 2.659.37 4.986-.006 7.655a.277.277 0 0 1-.55 0c-.331-2.63-.256-5.02 0-7.655a.277.277 0 0 1 .556 0Zm-1.663-.257c.27 2.726.39 5.171 0 7.904a.266.266 0 0 1-.532 0c-.38-2.69-.257-5.21 0-7.904a.266.266 0 0 1 .532 0Zm-1.647.77a26.108 26.108 0 0 1-.008 7.147.272.272 0 0 1-.542 0 27.955 27.955 0 0 1 0-7.147.275.275 0 0 1 .55 0Zm-1.67 1.769c.421 1.865.228 3.5-.029 5.388a.257.257 0 0 1-.514 0c-.21-1.858-.398-3.549 0-5.389a.272.272 0 0 1 .543 0Zm-1.655-.273c.388 1.897.26 3.508-.01 5.412-.026.28-.514.283-.54 0-.244-1.878-.347-3.54-.01-5.412a.283.283 0 0 1 .56 0Zm-1.668.911c.4 1.268.257 2.292-.026 3.572a.257.257 0 0 1-.514 0c-.241-1.262-.354-2.312-.023-3.572a.283.283 0 0 1 .563 0Z"/>`,
mail: `<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m3 6 9 7 9-7"/>`,
site: `<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18Z"/>`,
arrow: `<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>`,
download: `<path d="M12 3v12"/><path d="m7 11 5 5 5-5"/><path d="M5 21h14"/>`,
external: `<path d="M7 17 17 7"/><path d="M8 7h9v9"/>`,

View File

@@ -4,6 +4,7 @@ import type { Project } from "../data/content";
const STATUS: Record<Project["status"], { label: string; color: string }> = {
live: { label: "en ligne", color: "var(--accent)" },
dev: { label: "en développement", color: "var(--warn)" },
archived: { label: "archivé", color: "var(--text-dim)" },
oss: { label: "open-source", color: "var(--info)" },
};
@@ -12,11 +13,20 @@ interface Props { project: Project; }
const { project: p } = Astro.props;
const st = STATUS[p.status];
const mono = p.title.replace(/[^A-Za-z0-9]/g, "").slice(0, 2);
// Toute la carte devient cliquable (nouvel onglet) dès qu'un lien est défini.
const Tag = p.link ? "a" : "article";
const linkAttrs = p.link
? { href: p.link, target: "_blank", rel: "noopener", "aria-label": `${p.title}, voir le projet (nouvel onglet)` }
: {};
---
<article class="card">
<div class="card-media">
<Tag class:list={["card", p.link && "card--link"]} {...linkAttrs}>
<div class:list={["card-media", p.imgDark && "card-media--themed"]}>
{p.img ? (
<img src={p.img} alt={p.title} loading="lazy" />
<>
<img class="logo-light" src={p.img} alt={p.title} loading="lazy" />
{p.imgDark && <img class="logo-dark" src={p.imgDark} alt={p.title} loading="lazy" />}
</>
) : (
<div class="card-mono"><span>{mono}</span></div>
)}
@@ -25,9 +35,9 @@ const mono = p.title.replace(/[^A-Za-z0-9]/g, "").slice(0, 2);
<div class="card-head">
<span class="status"><span class="status-dot" style={`background:${st.color}`}></span>{st.label}</span>
{p.link && (
<a class="ext-link" href={p.link} target="_blank" rel="noopener" aria-label="Voir le projet (nouvel onglet)">
<span class="ext-link" aria-hidden="true">
<Icon name="external" size={16} />
</a>
</span>
)}
</div>
<h3 class="card-title">{p.title}</h3>
@@ -37,4 +47,4 @@ const mono = p.title.replace(/[^A-Za-z0-9]/g, "").slice(0, 2);
{p.stack.map((s) => <span class="chip">{s}</span>)}
</div>
</div>
</article>
</Tag>

View File

@@ -1,6 +1,6 @@
---
import ProjectCard from "./ProjectCard.astro";
import { projectsFeatured, projectsOther } from "../data/content";
import { projects } from "../data/content";
---
<section class="section container" id="projets">
<div class="reveal" style="margin-bottom:36px;">
@@ -8,13 +8,7 @@ import { projectsFeatured, projectsOther } from "../data/content";
<h2 class="h2">Réalisations</h2>
</div>
<div class="reveal label-mono" style="margin-bottom:16px;">// projets phares</div>
<div class="reveal grid-projects">
{projectsFeatured.map((p) => <ProjectCard project={p} />)}
</div>
<div class="reveal label-mono" style="margin:38px 0 16px;">// autres projets</div>
<div class="reveal grid-projects is-other">
{projectsOther.map((p) => <ProjectCard project={p} />)}
{projects.map((p) => <ProjectCard project={p} />)}
</div>
</section>

View File

@@ -1,6 +1,7 @@
---
import TechIcon from "./TechIcon.astro";
import { stackMain, stackSecondary, softTech, softHuman } from "../data/content";
import { stack, softTech, softHuman } from "../data/content";
import { brandColor } from "../lib/techIcons";
---
<section class="section container" id="stack">
<div class="reveal" style="margin-bottom:36px;">
@@ -9,27 +10,18 @@ import { stackMain, stackSecondary, softTech, softHuman } from "../data/content"
</div>
<div class="reveal stack-grid">
{stackMain.map((g) => (
{stack.map((g) => (
<div class="stack-group">
<div class="stack-group-label">{g.label}</div>
<div class="stack-group-label">{g.label}<span class="stack-group-count">{g.items.length}</span></div>
<div class="tiles">
{g.items.map((it) => (
<div class="tile"><TechIcon name={it} /><span class="tile-name">{it}</span></div>
))}
{g.items.map((it) => {
const c = brandColor(it);
return (
<div class="tile" style={c ? `--brand:${c}` : undefined}>
<TechIcon name={it} /><span class="tile-name">{it}</span>
</div>
</div>
))}
</div>
<div class="reveal label-mono" style="margin:36px 0 16px;">// également à l'aise avec</div>
<div class="reveal stack-grid is-secondary">
{stackSecondary.map((g) => (
<div class="stack-group">
<div class="stack-group-label">{g.label}</div>
<div class="tiles">
{g.items.map((it) => (
<div class="tile"><TechIcon name={it} /><span class="tile-name">{it}</span></div>
))}
);
})}
</div>
</div>
))}

View File

@@ -6,11 +6,13 @@ interface Props { name: string; }
const { name } = Astro.props;
const def: TechDef | undefined = techIcons[name];
const path = def && "path" in def ? def.path : null;
const vb = def && "path" in def && def.vb ? def.vb : "0 0 24 24";
const fr = def && "path" in def && def.fr ? def.fr : undefined;
const mono = def && "mono" in def ? def.mono : monogram(name);
---
{path ? (
<svg class="ic" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d={path} />
<svg class="ic" viewBox={vb} fill="currentColor" aria-hidden="true">
<path d={path} fill-rule={fr} />
</svg>
) : (
<span class="tile-mono">{mono}</span>

View File

@@ -4,7 +4,7 @@
c'est ici que ça se passe. Tout est typé.
============================================================ */
export type ProjectStatus = "live" | "archived" | "oss";
export type ProjectStatus = "live" | "dev" | "archived" | "oss";
export interface Project {
id: string;
@@ -15,6 +15,8 @@ export interface Project {
status: ProjectStatus;
link?: string;
img?: string;
/** Variante du logo pour le thème sombre (logo « blanc »). Si absent, `img` est utilisé partout. */
imgDark?: string;
}
export interface Experience {
@@ -51,9 +53,9 @@ export const profile = {
name: "Johan Leroy",
role: "Développeur fullstack",
lede:
"Je conçois et développe des applications web performantes, propres et scalables du front jusqu'à la mise en production. Basé à Nantes, profil « builder » : open-source, auto-hébergement, projets perso.",
"Je conçois et développe des applications web performantes, propres et scalables, du front jusqu'à la mise en production. Basé à Nantes, profil « builder » : open-source, auto-hébergement, projets perso.",
bio:
"Développeur fullstack passionné par les technologies modernes. J'aime construire des produits complets penser l'archi, soigner l'expérience, déployer et maintenir. J'expérimente en continu via mes projets perso, mes services auto-hébergés et l'open-source.",
"Développeur fullstack passionné par les technologies modernes, j'aime construire des produits complets : penser l'architecture, soigner l'expérience, déployer et maintenir. J'expérimente en continu via mes projets perso, mes services auto-hébergés et l'open-source.",
};
export const social = {
@@ -61,7 +63,7 @@ export const social = {
github: "https://github.com/JohanLeroy",
gitea: "https://git.lidge.fr/johanleroy",
linkedin: "https://www.linkedin.com/in/johan-leroy-472409180/",
site: "https://johanleroy.fr",
soundcloud: "https://soundcloud.com/skuuuu",
};
/* URL du bouton « café » — à remplacer par le service retenu
@@ -75,72 +77,82 @@ export const stats = [
{ n: "OSS", l: "contributeur open-source" },
];
export const projectsFeatured: Project[] = [
/* Liste unique, dans l'ordre de développement (du plus récent au plus ancien). */
export const projects: Project[] = [
{
id: "kys",
title: "Keep Your Smile",
subtitle: "Site e-commerce",
desc:
"Site client e-commerce de la marque de vêtement Keep Your Smile",
stack: ["Next.js", "NestJS 11"],
status: "dev",
link: "https://keepyoursmile.fr",
img: "/images/projets/kys.png",
imgDark: "/images/projets/kys-dark.png",
},
{
id: "techos",
title: "Techos",
subtitle: "Plateforme hébergement & domaines",
desc:
"Plateforme client + admin de gestion d'hébergement web et de domaines pour une association, bâtie sur WHMCS. Design system « Liquid Glass » clair/sombre.",
"Plateforme client + admin de gestion d'hébergement web et cloud pour une association, bâtie sur WHMCS.",
stack: ["Next.js 16", "React 19", "NestJS 11", "Tailwind 4", "shadcn/ui", "Redis", "WHMCS API"],
status: "live",
status: "dev",
link: "https://techos-asso.fr",
img: "/images/projets/techos.png",
},
{
id: "amarea",
title: "Amarea Tattoo",
subtitle: "Site du studio de tatouage Amarea",
desc:
"Site vitrine + gestion pour un studio de tatouage. Architecture découplée SPA + API REST avec gestion de médias.",
stack: ["Vue 3", "Vite", "NestJS 11", "TypeORM", "MariaDB", "Tailwind 4"],
status: "live",
},
{
id: "lidge",
title: "Lidge",
subtitle: "Portail d'abonnements aux services auto-hébergés",
desc:
"Plateforme perso pour gérer les abonnements à mes services auto-hébergés (Jellyfin, Cloud, Vault, Mail, Git…) avec SSO, tickets et workflow d'accès.",
stack: ["Nuxt 3", "NestJS 11", "TypeORM", "MariaDB", "JWT", "Nodemailer"],
status: "live",
},
];
export const projectsOther: Project[] = [
{
id: "arboretum",
title: "Arboretum",
subtitle: "Pilotage de worktrees git + agents IA",
desc:
"Outil open-source : dashboard web pour piloter des worktrees git et des sessions d'agents IA. Lancé en npx, propulsé par un daemon Node.",
stack: ["Node.js", "Vue 3", "TypeScript", "npx"],
status: "oss",
link: "https://github.com/JohanLeroy",
imgDark: "/images/projets/techos-dark.png",
},
{
id: "ksauce",
title: "Bot Discord KSauce",
subtitle: "Bot communautaire Discord",
desc:
"Bot Discord pour la communauté KSauce : commandes, modération et fonctionnalités sur-mesure.",
"Communauté KSauce : commandes, modération et fonctionnalités sur-mesure.",
stack: ["Node.js", "TypeScript", "Discord.js"],
status: "dev",
link: "https://ksauce.lidge.fr/",
img: "/images/projets/ksauce.png",
imgDark: "/images/projets/ksauce-dark.png",
},
{
id: "amarea",
title: "Amarea Tattoo",
subtitle: "Site vitrine",
desc:
"Vitrine + gestion pour une tatoueuse indépendante. Architecture découplée SPA + API REST avec gestion de médias.",
stack: ["Vue 3", "Vite", "NestJS 11", "TypeORM", "MariaDB", "Tailwind 4"],
status: "live",
link: "https://amarea-tattoo.fr",
img: "/images/projets/amarea.png",
},
{
id: "arboretum",
title: "Arboretum",
subtitle: "IDE nouvelle génération",
desc:
"Outil IDE open-source : piloter des worktrees git et des sessions d'agents IA.",
stack: ["Node.js", "Vue 3", "TypeScript", "npx"],
status: "oss",
link: "https://git-arboretum.com/",
img: "/images/projets/arboretum.png",
imgDark: "/images/projets/arboretum-dark.png",
},
{
id: "lplv",
title: "Liberty Pour La Vie",
subtitle: "Site associatif",
desc: "Site web complet pour l'association Liberty Pour La Vie. Projet désormais fermé.",
desc: "Gestion complet pour l'association Liberty Pour La Vie pour facilité l'adoption d'un chat.",
stack: ["Java", "Spring Boot", "Thymeleaf", "Bootstrap", "MariaDB"],
status: "archived",
status: "live",
link: "https://libertypourlavie.fr/",
img: "/images/projets/lplv.png",
},
{
id: "mvg",
title: "Mon Voisin Geek",
subtitle: "Site vitrine",
desc: "Site vitrine simple pour Mon Voisin Geek. Projet désormais fermé.",
desc: "Vitrine de service informatique de voisinage. Projet désormais fermé.",
stack: ["Angular", "Tailwind CSS", "Express.js", "MariaDB"],
status: "archived",
img: "/images/projets/monvoisingeek.png",
@@ -148,12 +160,31 @@ export const projectsOther: Project[] = [
];
export const experiences: Experience[] = [
{
company: "Qualiobee",
logo: "/images/entreprises/qualiobee.png",
role: "Développeur fullstack",
period: "Depuis 2025",
duration: "1 an",
desc:
"Édition d'un SaaS de gestion pour organismes de formation (conformité Qualiopi) : , extranet et intégration facturation et LMS.",
missions: [
"Développement fullstack : API NestJS + front Nuxt",
"Architecture serverless AWS (Lambda, S3)",
"Intégration Pennylane & Tiime (devis, factures, synchronisation clients)",
"Synchronisation calendrier ICS",
"Extranet apprenant",
"Intégration LMS",
],
stack: ["NestJS", "Nuxt 2", "TypeORM", "MariaDB", "AWS", "Terraform"],
current: true,
},
{
company: "Almeria",
logo: "/images/entreprises/almeria.png",
role: "Développeur",
period: "Depuis 2023",
duration: "3 ans",
period: "20232025",
duration: "2 ans",
desc: "Conception et développement d'applications web et de logiciels d'intégration ERP.",
missions: [
"Analyse, conception & développement d'applications web",
@@ -161,13 +192,12 @@ export const experiences: Experience[] = [
"Maintenance & évolution des solutions",
],
stack: ["Angular", "Express.js", "NestJS", "SQL Server", "MariaDB"],
current: true,
},
{
company: "CEREMA",
logo: "/images/entreprises/cerema.png",
role: "Technicien évaluation mesure trafic & mobilité",
period: "20212023",
role: "Technicien évaluation, mesure trafic & mobilité",
period: "20212023",
duration: "2 ans",
desc: "Installation de capteurs et analyse de données de mobilité.",
missions: [
@@ -181,7 +211,7 @@ export const experiences: Experience[] = [
company: "Réseau SNCF",
logo: "/images/entreprises/sncf.png",
role: "Agent télécom",
period: "20192021",
period: "20192021",
duration: "2 ans",
desc:
"Maintenance des équipements de sonorisation, d'affichage et de téléphonie sur le réseau ferroviaire.",
@@ -226,22 +256,28 @@ export const experiences: Experience[] = [
];
export const formation: Formation[] = [
{
title: "Expert en Architecture et Développement Logiciel",
school: "Campus ENI, Nantes",
period: "Depuis 2025",
desc: "Architecture logicielle avancée, conception de systèmes distribués, DevOps, cloud, sécurité, performance et pilotage de projets techniques",
},
{
title: "Concepteur Développeur d'Applications",
school: "Campus ENI Nantes",
period: "Depuis 2023",
school: "Campus ENI, Nantes",
period: "2023-2025",
desc: "Conception & dév d'applications web/mobiles, architecture logicielle, gestion de projet.",
},
{
title: "BTS Systèmes Numériques",
school: "Saint Félix La Salle Nantes",
period: "20212023",
school: "Saint Félix La Salle, Nantes",
period: "20212023",
desc: "Systèmes électroniques & informatiques, informatique industrielle & réseaux.",
},
{
title: "BAC PRO Systèmes Numériques",
school: "Saint Félix La Salle Nantes",
period: "20182021",
school: "Saint Félix La Salle, Nantes",
period: "20182021",
desc: "Systèmes électroniques & réseaux, installation & maintenance.",
},
{
@@ -251,20 +287,16 @@ export const formation: Formation[] = [
},
];
export const stackMain: StackGroup[] = [
{ label: "Langages", items: ["TypeScript", "JavaScript", "Java", "Python", "PHP"] },
{ label: "Frameworks", items: ["NestJS", "Nuxt", "Next.js", "Vue.js", "Angular", "Tailwind CSS", "Astro"] },
{ label: "Bases de données", items: ["MariaDB", "MySQL", "PostgreSQL", "Redis"] },
{ label: "Outils & DevOps", items: ["Docker", "Git", "AWS", "Terraform", "NGINX", "Plesk"] },
{ label: "IDE & Éditeurs", items: ["WebStorm", "PhpStorm", "PyCharm", "Claude Code"] },
{ label: "Systèmes", items: ["Debian", "Ubuntu", "macOS"] },
];
export const stackSecondary: StackGroup[] = [
{ label: "Langages", items: ["C#", "C++", "Dart", "Kotlin"] },
{ label: "Frameworks & libs", items: ["Spring", "Symfony", "Express.js", "FastAPI", "Flutter", "GraphQL", "Vuetify", "Bootstrap"] },
{ label: "Bases de données", items: ["MongoDB", "SQLite"] },
{ label: "Services & outils", items: ["Firebase", "Swagger", "Postman", "Stripe"] },
/* Grille unique, groupée par catégorie (technos « cœur » d'abord, puis le reste). */
export const stack: StackGroup[] = [
{ label: "Langages", items: ["TypeScript", "JavaScript", "Java", "Python", "PHP", "C#", "C++", "Dart", "Kotlin", "HTML5", "CSS3"] },
{ label: "Frameworks & libs", items: ["NestJS", "Nuxt", "Next.js", "React", "Vue.js", "Angular", "Astro", "Tailwind CSS", "Spring", "Symfony", "Express.js", "FastAPI", "Flutter", "GraphQL", "Socket.io", "Chart.js", "Discord.js", "Vuetify", "Bootstrap"] },
{ label: "Bases de données & ORM", items: ["MariaDB", "MySQL", "PostgreSQL", "Redis", "MongoDB", "SQLite", "TypeORM", "Prisma", "Sequelize"] },
{ label: "Outils & DevOps", items: ["Docker", "Git", "Gitea", "GitLab", "GitHub Actions", "AWS", "Terraform", "NGINX", "Apache", "Vite", "pnpm", "Plesk"] },
{ label: "Tests & Qualité", items: ["Jest", "Vitest", "Playwright", "ESLint", "Prettier"] },
{ label: "Services & outils", items: ["Firebase", "Swagger", "Postman", "Stripe", "HubSpot"] },
{ label: "IDE & Éditeurs", items: ["WebStorm", "PhpStorm", "PyCharm", "IntelliJ IDEA", "VS Code", "Claude Code"] },
{ label: "Systèmes", items: ["Debian", "Ubuntu", "Windows", "macOS"] },
];
export const softTech = [
@@ -303,6 +335,5 @@ $ cat about.json
"name": "Johan Leroy",
"role": "Développeur fullstack",
"stack": ["TypeScript", "NestJS", "Nuxt", "Next.js"],
"location": "Nantes, France",
"status": "ouvert aux opportunités"
"location": "Nantes, France"
}`;

View File

@@ -7,7 +7,7 @@ interface Props {
description?: string;
}
const {
title = "Johan Leroy Développeur fullstack",
title = "Johan Leroy, Développeur fullstack",
description = "Johan Leroy, développeur fullstack basé à Nantes. TypeScript, NestJS, Nuxt/Vue, Next.js. Conception d'applications web performantes, du front à la mise en production.",
} = Astro.props;
@@ -15,7 +15,7 @@ const siteUrl = Astro.site?.href ?? "https://johanleroy.fr/";
const canonical = new URL(Astro.url.pathname, siteUrl).href;
const ogImage = new URL("/images/og.png", siteUrl).href;
const ogDescription =
"Développeur fullstack à Nantes — applications web performantes, propres et scalables.";
"Développeur fullstack à Nantes. Applications web performantes, propres et scalables.";
// Données structurées (schema.org Person) — aide les moteurs à comprendre la page.
const jsonLd = {
@@ -27,7 +27,7 @@ const jsonLd = {
image: ogImage,
email: social.email,
address: { "@type": "PostalAddress", addressLocality: "Nantes", addressCountry: "FR" },
sameAs: [social.github, social.linkedin, social.gitea],
sameAs: [social.github, social.linkedin, social.gitea, social.soundcloud],
knowsAbout: ["TypeScript", "NestJS", "Nuxt", "Next.js", "Vue.js", "Astro", "Docker", "AWS", "Terraform"],
};
---
@@ -51,7 +51,7 @@ const jsonLd = {
<meta property="og:image" content={ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Johan Leroy Développeur fullstack" />
<meta property="og:image:alt" content="Johan Leroy, Développeur fullstack" />
{/* Twitter */}
<meta name="twitter:card" content="summary_large_image" />

View File

@@ -12,69 +12,129 @@ import {
siCplusplus, siDart, siKotlin, siSpring, siSymfony, siExpress, siFastapi,
siFlutter, siGraphql, siVuetify, siBootstrap, siMongodb, siSqlite, siFirebase,
siSwagger, siPostman, siStripe,
siHtml5, siCss3, siReact, siSocketdotio, siChartdotjs, siDiscord,
siTypeorm, siPrisma, siSequelize, siApache, siVite, siGitea, siGitlab,
siGithubactions, siPnpm, siHubspot, siIntellijidea, siJest, siVitest,
siEslint, siPrettier,
} from "simple-icons";
export type TechDef = { path: string } | { mono: string };
export type TechDef = { path: string; hex?: string; vb?: string; fr?: "evenodd" | "nonzero" } | { mono: string };
/* Logos absents de simple-icons : SVG monochromes Devicon (MIT), viewBox 128. */
const DEVICON_VB = "0 0 128 128";
const javaPath = "M47.617 98.12c-19.192 5.362 11.677 16.439 36.115 5.969-4.003-1.556-6.874-3.351-6.874-3.351-10.897 2.06-15.952 2.222-25.844 1.092-8.164-.935-3.397-3.71-3.397-3.71zm33.189-10.46c-14.444 2.779-22.787 2.69-33.354 1.6-8.171-.845-2.822-4.805-2.822-4.805-21.137 7.016 11.767 14.977 41.309 6.336-3.14-1.106-5.133-3.131-5.133-3.131zm11.319-60.575c.001 0-42.731 10.669-22.323 34.187 6.024 6.935-1.58 13.17-1.58 13.17s15.289-7.891 8.269-17.777c-6.559-9.215-11.587-13.793 15.634-29.58zm9.998 81.144s3.529 2.91-3.888 5.159c-14.102 4.272-58.706 5.56-71.095.171-4.45-1.938 3.899-4.625 6.526-5.192 2.739-.593 4.303-.485 4.303-.485-4.952-3.487-32.013 6.85-13.742 9.815 49.821 8.076 90.817-3.637 77.896-9.468zM85 77.896c2.395-1.634 5.703-3.053 5.703-3.053s-9.424 1.685-18.813 2.474c-11.494.964-23.823 1.154-30.012.326-14.652-1.959 8.033-7.348 8.033-7.348s-8.812-.596-19.644 4.644C17.455 81.134 61.958 83.958 85 77.896zm5.609 15.145c-.108.29-.468.616-.468.616 31.273-8.221 19.775-28.979 4.822-23.725-1.312.464-2 1.543-2 1.543s.829-.334 2.678-.72c7.559-1.575 18.389 10.119-5.032 22.286zM64.181 70.069c-4.614-10.429-20.26-19.553.007-35.559C89.459 14.563 76.492 1.587 76.492 1.587c5.23 20.608-18.451 26.833-26.999 39.667-5.821 8.745 2.857 18.142 14.688 28.815zm27.274 51.748c-19.187 3.612-42.854 3.191-56.887.874 0 0 2.874 2.38 17.646 3.331 22.476 1.437 57-.8 57.816-11.436.001 0-1.57 4.032-18.575 7.231z";
const csharpPath = "M109 50h-4.8l-1.2 6h-3.8l1.2-6h-4.9l-1.2 6H89v5h4.4l-.9 4H89v5h2.5l-1.2 6h4.8l1.2-6h3.8l-1.2 6h4.9l1.2-6h5v-5h-4.1l.9-4h3.2v-5h-2.2l1.2-6zm-7.9 15h-3.8l.9-4h3.8l-.9 4zm15.4-32.7c-.6-1.1-1.4-2.1-2.3-2.6L66.1 1.9c-.8-.5-1.9-.7-3.1-.7-1.2 0-2.3.3-3.1.7L11.4 29.7c-1.7 1-3.4 3.5-3.4 5.4v55.7c0 1.1.7 2.3 1.4 3.4l.1.1c.5.8 1.3 1.5 2 1.9l48.3 27.9c.8.5 2 .7 3.2.7 1.2 0 2.3-.3 3.1-.7l47.5-27.9c1.7-1 2.4-3.5 2.4-5.4V35.1c0-.8.4-1.8 0-2.6l.5-.2zm-4.2 2.1c0 .3-.3.5-.3.7v55.7c0 .8-.2 1.7-.4 2L64 120.6c-.1.1-.5.2-1.1.2-.6 0-1-.1-1.1-.2L13.6 92.8s-.1-.1-.2-.1l-.6-.6c-.4-.7.2-1.1-.8-1.2V35.2c1-.5.9-1.7 1.4-1.9L61.7 5.4c.1 0 .6-.2 1.2-.2s1 .1 1.1.2l48 27.7.4.9c.1.1-.1.3-.1.4zM63 87.5c-13.5 0-24.5-11-24.5-24.5s11-24.5 24.5-24.5c9.1 0 17.1 5 21.3 12.5l13-7.5c-6.8-11.9-19.6-20-34.3-20-21.8 0-39.5 17.7-39.5 39.5s17.7 39.5 39.5 39.5c14.6 0 27.4-8 34.2-19.8l-12.9-7.6C80.1 82.5 72.1 87.5 63 87.5z";
/* VS Code & Windows : absents de simple-icons (marques protégées). VS Code = tracé
Devicon « plain » (nécessite fill-rule evenodd pour l'encoche) ; Windows = logo
4-carreaux dessiné à la main (net en monochrome). viewBox 128. */
const vscodePath = "M90.767 127.126a7.968 7.968 0 0 0 6.35-.244l26.353-12.681a8 8 0 0 0 4.53-7.209V21.009a8 8 0 0 0-4.53-7.21L97.117 1.12a7.97 7.97 0 0 0-9.093 1.548l-50.45 46.026L15.6 32.013a5.328 5.328 0 0 0-6.807.302l-7.048 6.411a5.335 5.335 0 0 0-.006 7.888L20.796 64 1.74 81.387a5.336 5.336 0 0 0 .006 7.887l7.048 6.411a5.327 5.327 0 0 0 6.807.303l21.974-16.68 50.45 46.025a7.96 7.96 0 0 0 2.743 1.793Zm5.252-92.183L57.74 64l38.28 29.058V34.943Z";
const windowsPath = "M12 12h50v50H12z M66 12h50v50H66z M12 66h50v50H12z M66 66h50v50H66z";
/* Playwright : tracé Devicon « plain » (masque de théâtre). Rendu en nonzero
(fill-rule par défaut) — l'evenodd casserait les creux du masque. */
const playwrightPath = "m72.086 86.132-.594-.144c-13.125-3.844-15.15-14.311-15.15-14.311l18.182 5.082L84.15 39.77l-.116-.031c-11.807-3.162-19.64-8.692-22.744-11.292-4.4-3.685-6.335-6.246-8.24-2.372-1.682 3.417-3.836 8.977-5.92 16.762-4.516 16.857-7.892 52.429 20.027 59.914l.572.129zm-18.807-30.85s4.4-6.843 11.862-4.722c7.467 2.121 8.045 10.376 8.045 10.376zm-8.517 23.451L31.787 82.41s1.41-8.029 10.968-11.212l-7.347-27.573-.635.193c-9.111 2.457-16.476 1.805-19.55 1.273-4.357-.751-6.636-1.708-6.422 1.606.186 2.923.882 7.454 2.477 13.44 3.45 12.961 14.854 37.937 36.405 32.132l.635-.199-3.555-13.337ZM19.548 60.315l15.316-4.035s-.446 5.892-6.188 7.405c-5.743 1.512-9.128-3.371-9.128-3.371zm89.824-18.979c-3.981.698-13.532 1.567-25.336-1.596-11.807-3.162-19.64-8.692-22.744-11.292-4.4-3.685-6.335-6.246-8.24-2.372-1.684 3.417-3.837 8.977-5.921 16.762-4.516 16.857-7.892 52.429 20.027 59.914 27.912 7.479 42.772-25.017 47.289-41.875 2.084-7.783 2.998-13.676 3.25-17.476.287-4.305-2.67-3.055-8.324-2.064zM53.28 55.282s4.4-6.843 11.862-4.722c7.467 2.121 8.045 10.376 8.045 10.376zm18.215 30.706c-13.125-3.845-15.15-14.311-15.15-14.311l35.259 9.858c0-.002-7.117 8.25-20.109 4.453zm12.466-21.51s4.394-6.838 11.854-4.711c7.46 2.124 8.048 10.379 8.048 10.379zM51.732 83.935v-7.179l-19.945 5.656s1.474-8.563 11.876-11.514c3.155-.894 5.846-.888 8.069-.459V40.995h9.987c-1.087-3.36-2.139-5.947-3.023-7.744-1.461-2.975-2.96-1.003-6.361 1.842-2.396 2.001-8.45 6.271-17.561 8.726-9.111 2.457-16.476 1.805-19.55 1.273-4.357-.752-6.636-1.708-6.422 1.605.186 2.923.882 7.455 2.477 13.44 3.45 12.962 14.854 37.937 36.405 32.132 5.629-1.517 9.603-4.515 12.357-8.336h-8.309Zm-32.185-23.62 15.316-4.035s-.446 5.892-6.188 7.405c-5.743 1.512-9.128-3.371-9.128-3.371z";
/* Couleur de marque (simple-icons `.hex`, sans `#`) conservée uniquement si sa
luminance relative est « moyenne » (lisible en thème clair ET sombre). Les
marques quasi-noires/quasi-blanches (Next.js, Express, macOS, AWS, SQLite…)
retombent sur `undefined` → l'emerald (--accent) est utilisé au survol. */
function brand(hex: string): string | undefined {
const h = hex.replace("#", "");
const ch = (i: number) => parseInt(h.slice(i, i + 2), 16) / 255;
const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const L = 0.2126 * lin(ch(0)) + 0.7152 * lin(ch(2)) + 0.0722 * lin(ch(4));
return L >= 0.12 && L <= 0.85 ? `#${h}` : undefined;
}
export const techIcons: Record<string, TechDef> = {
// Langages
"TypeScript": { path: siTypescript.path },
"JavaScript": { path: siJavascript.path },
"Java": { mono: "Ja" }, // absent de simple-icons
"Python": { path: siPython.path },
"PHP": { path: siPhp.path },
"TypeScript": { path: siTypescript.path, hex: brand(siTypescript.hex) },
"JavaScript": { path: siJavascript.path, hex: brand(siJavascript.hex) },
"Java": { path: javaPath, hex: "#EA2D2E", vb: DEVICON_VB }, // Devicon (absent de simple-icons)
"Python": { path: siPython.path, hex: brand(siPython.hex) },
"PHP": { path: siPhp.path, hex: brand(siPhp.hex) },
"HTML5": { path: siHtml5.path, hex: brand(siHtml5.hex) },
"CSS3": { path: siCss3.path, hex: brand(siCss3.hex) },
// Frameworks
"NestJS": { path: siNestjs.path },
"Nuxt": { path: siNuxtdotjs.path },
"Next.js": { path: siNextdotjs.path },
"Vue.js": { path: siVuedotjs.path },
"Angular": { path: siAngular.path },
"Tailwind CSS": { path: siTailwindcss.path },
"Astro": { path: siAstro.path },
// Bases de données
"MariaDB": { path: siMariadb.path },
"MySQL": { path: siMysql.path },
"PostgreSQL": { path: siPostgresql.path },
"Redis": { path: siRedis.path },
"MongoDB": { path: siMongodb.path },
"SQLite": { path: siSqlite.path },
"NestJS": { path: siNestjs.path, hex: brand(siNestjs.hex) },
"Nuxt": { path: siNuxtdotjs.path, hex: brand(siNuxtdotjs.hex) },
"Next.js": { path: siNextdotjs.path, hex: brand(siNextdotjs.hex) },
"React": { path: siReact.path, hex: brand(siReact.hex) },
"Vue.js": { path: siVuedotjs.path, hex: brand(siVuedotjs.hex) },
"Angular": { path: siAngular.path, hex: brand(siAngular.hex) },
"Tailwind CSS": { path: siTailwindcss.path, hex: brand(siTailwindcss.hex) },
"Astro": { path: siAstro.path, hex: brand(siAstro.hex) },
"Socket.io": { path: siSocketdotio.path, hex: brand(siSocketdotio.hex) },
"Chart.js": { path: siChartdotjs.path, hex: brand(siChartdotjs.hex) },
"Discord.js": { path: siDiscord.path, hex: brand(siDiscord.hex) }, // icône Discord
// Bases de données & ORM
"MariaDB": { path: siMariadb.path, hex: brand(siMariadb.hex) },
"MySQL": { path: siMysql.path, hex: brand(siMysql.hex) },
"PostgreSQL": { path: siPostgresql.path, hex: brand(siPostgresql.hex) },
"Redis": { path: siRedis.path, hex: brand(siRedis.hex) },
"MongoDB": { path: siMongodb.path, hex: brand(siMongodb.hex) },
"SQLite": { path: siSqlite.path, hex: brand(siSqlite.hex) },
"TypeORM": { path: siTypeorm.path, hex: brand(siTypeorm.hex) },
"Prisma": { path: siPrisma.path, hex: brand(siPrisma.hex) },
"Sequelize": { path: siSequelize.path, hex: brand(siSequelize.hex) },
// Outils & DevOps
"Docker": { path: siDocker.path },
"Git": { path: siGit.path },
"AWS": { path: siAmazonwebservices.path },
"Terraform": { path: siTerraform.path },
"NGINX": { path: siNginx.path },
"Plesk": { path: siPlesk.path },
"Docker": { path: siDocker.path, hex: brand(siDocker.hex) },
"Git": { path: siGit.path, hex: brand(siGit.hex) },
"Gitea": { path: siGitea.path, hex: brand(siGitea.hex) },
"GitLab": { path: siGitlab.path, hex: brand(siGitlab.hex) },
"GitHub Actions": { path: siGithubactions.path, hex: brand(siGithubactions.hex) },
"AWS": { path: siAmazonwebservices.path, hex: brand(siAmazonwebservices.hex) },
"Terraform": { path: siTerraform.path, hex: brand(siTerraform.hex) },
"NGINX": { path: siNginx.path, hex: brand(siNginx.hex) },
"Apache": { path: siApache.path, hex: brand(siApache.hex) },
"Vite": { path: siVite.path, hex: brand(siVite.hex) },
"pnpm": { path: siPnpm.path, hex: brand(siPnpm.hex) },
"Plesk": { path: siPlesk.path, hex: brand(siPlesk.hex) },
// Tests & Qualité
"Jest": { path: siJest.path, hex: brand(siJest.hex) },
"Vitest": { path: siVitest.path, hex: brand(siVitest.hex) },
"Playwright": { path: playwrightPath, hex: "#2EAD33", vb: DEVICON_VB }, // Devicon plain (absent de simple-icons)
"ESLint": { path: siEslint.path, hex: brand(siEslint.hex) },
"Prettier": { path: siPrettier.path, hex: brand(siPrettier.hex) },
// IDE & Éditeurs
"WebStorm": { path: siWebstorm.path },
"PhpStorm": { path: siPhpstorm.path },
"PyCharm": { path: siPycharm.path },
"Claude Code": { path: siClaude.path },
"WebStorm": { path: siWebstorm.path, hex: brand(siWebstorm.hex) },
"PhpStorm": { path: siPhpstorm.path, hex: brand(siPhpstorm.hex) },
"PyCharm": { path: siPycharm.path, hex: brand(siPycharm.hex) },
"IntelliJ IDEA": { path: siIntellijidea.path, hex: brand(siIntellijidea.hex) },
"VS Code": { path: vscodePath, hex: "#007ACC", vb: DEVICON_VB, fr: "evenodd" }, // Devicon plain (absent de simple-icons)
"Claude Code": { path: siClaude.path, hex: brand(siClaude.hex) },
// Systèmes
"Debian": { path: siDebian.path },
"Ubuntu": { path: siUbuntu.path },
"macOS": { path: siApple.path },
"Debian": { path: siDebian.path, hex: brand(siDebian.hex) },
"Ubuntu": { path: siUbuntu.path, hex: brand(siUbuntu.hex) },
"Windows": { path: windowsPath, hex: "#0078D4", vb: DEVICON_VB }, // logo 4-carreaux (absent de simple-icons)
"macOS": { path: siApple.path, hex: brand(siApple.hex) },
// Secondaire — langages
"C#": { mono: "C#" }, // absent de simple-icons
"C++": { path: siCplusplus.path },
"Dart": { path: siDart.path },
"Kotlin": { path: siKotlin.path },
"C#": { path: csharpPath, hex: "#9B4F96", vb: DEVICON_VB }, // Devicon (absent de simple-icons)
"C++": { path: siCplusplus.path, hex: brand(siCplusplus.hex) },
"Dart": { path: siDart.path, hex: brand(siDart.hex) },
"Kotlin": { path: siKotlin.path, hex: brand(siKotlin.hex) },
// Secondaire — frameworks & libs
"Spring": { path: siSpring.path },
"Symfony": { path: siSymfony.path },
"Express.js": { path: siExpress.path },
"FastAPI": { path: siFastapi.path },
"Flutter": { path: siFlutter.path },
"GraphQL": { path: siGraphql.path },
"Vuetify": { path: siVuetify.path },
"Bootstrap": { path: siBootstrap.path },
"Spring": { path: siSpring.path, hex: brand(siSpring.hex) },
"Symfony": { path: siSymfony.path, hex: brand(siSymfony.hex) },
"Express.js": { path: siExpress.path, hex: brand(siExpress.hex) },
"FastAPI": { path: siFastapi.path, hex: brand(siFastapi.hex) },
"Flutter": { path: siFlutter.path, hex: brand(siFlutter.hex) },
"GraphQL": { path: siGraphql.path, hex: brand(siGraphql.hex) },
"Vuetify": { path: siVuetify.path, hex: brand(siVuetify.hex) },
"Bootstrap": { path: siBootstrap.path, hex: brand(siBootstrap.hex) },
// Secondaire — services & outils
"Firebase": { path: siFirebase.path },
"Swagger": { path: siSwagger.path },
"Postman": { path: siPostman.path },
"Stripe": { path: siStripe.path },
"Firebase": { path: siFirebase.path, hex: brand(siFirebase.hex) },
"Swagger": { path: siSwagger.path, hex: brand(siSwagger.hex) },
"Postman": { path: siPostman.path, hex: brand(siPostman.hex) },
"Stripe": { path: siStripe.path, hex: brand(siStripe.hex) },
"HubSpot": { path: siHubspot.path, hex: brand(siHubspot.hex) },
};
/** Couleur de marque lisible d'une techno (ou `undefined` → fallback emerald). */
export function brandColor(name: string): string | undefined {
const d = techIcons[name];
return d && "hex" in d ? d.hex : undefined;
}
/** Monogramme de repli pour une techno sans icône (2 premières lettres). */
export function monogram(name: string): string {
const clean = name.replace(/[^A-Za-z0-9]/g, "");

View File

@@ -102,7 +102,6 @@ html[data-theme="light"] .icon-moon{display:block;}
.mobile-link{font-family:var(--font-mono);font-size:15px;color:var(--text);padding:11px 4px;border-bottom:1px solid var(--border-soft);}
.mobile-cta{display:flex;gap:10px;margin-top:14px;}
.mobile-cta a{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px;border-radius:9px;font-size:14px;font-weight:600;}
.mobile-cta .m-github{border:1px solid var(--border);color:var(--text);}
.mobile-cta .m-coffee{border:1px solid var(--coffee);color:var(--coffee);}
/* ---------- Hero ---------- */
@@ -147,27 +146,27 @@ html[data-theme="light"] .icon-moon{display:block;}
/* ---------- Projets ---------- */
.grid-projects{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:18px;}
.grid-projects.is-other{grid-template-columns:repeat(auto-fit,minmax(280px,1fr));}
.card{display:flex;flex-direction:column;border:1px solid var(--border);border-radius:14px;background:var(--surface-2);overflow:hidden;transition:transform .25s ease,border-color .25s ease,box-shadow .25s ease;}
.card:hover{transform:translateY(-4px);border-color:var(--accent);box-shadow:var(--shadow);}
.card-media{position:relative;height:168px;overflow:hidden;}
.is-other .card-media{height:140px;}
.card-media img{width:100%;height:100%;object-fit:cover;display:block;}
.card-mono{height:100%;display:flex;align-items:center;justify-content:center;background:radial-gradient(120% 130% at 0% 0%,color-mix(in srgb,var(--accent) 18%,transparent),transparent 58%),var(--surface);}
.card--link{cursor:pointer;text-decoration:none;color:inherit;}
.card--link:focus-visible{outline:2px solid var(--accent);outline-offset:3px;}
.card-media{position:relative;height:168px;overflow:hidden;background:var(--surface);padding:16px 20px;}
.card-media img{width:100%;height:100%;object-fit:contain;display:block;}
/* Swap du logo selon le thème (:root = sombre → on montre la variante claire/blanche). */
.card-media--themed .logo-light{display:none;}
[data-theme="light"] .card-media--themed .logo-light{display:block;}
[data-theme="light"] .card-media--themed .logo-dark{display:none;}
.card-mono{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:radial-gradient(120% 130% at 0% 0%,color-mix(in srgb,var(--accent) 18%,transparent),transparent 58%),var(--surface);}
.card-mono span{font-family:var(--font-mono);font-size:44px;font-weight:700;color:var(--accent);opacity:.92;}
.is-other .card-mono span{font-size:38px;}
.card-body{padding:18px 18px 20px;display:flex;flex-direction:column;gap:11px;flex:1;}
.is-other .card-body{padding:16px 16px 18px;gap:10px;}
.card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;}
.status{display:inline-flex;align-items:center;gap:6px;font-family:var(--font-mono);font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.05em;}
.status-dot{width:7px;height:7px;border-radius:50%;}
.ext-link{color:var(--text-muted);display:inline-flex;}
.ext-link:hover{color:var(--accent);}
.ext-link{color:var(--text-muted);display:inline-flex;transition:color .25s ease;}
.card--link:hover .ext-link{color:var(--accent);}
.card-title{font-size:19px;font-weight:700;letter-spacing:-.02em;color:var(--text);}
.is-other .card-title{font-size:17px;}
.card-sub{font-family:var(--font-mono);font-size:12px;color:var(--accent);}
.card-desc{font-size:14px;color:var(--text-muted);line-height:1.55;}
.is-other .card-desc{font-size:13.5px;}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:auto;padding-top:6px;}
.chip{font-family:var(--font-mono);font-size:11px;padding:4px 9px;border:1px solid var(--border);border-radius:999px;color:var(--text-muted);background:var(--bg);}
@@ -195,26 +194,29 @@ html[data-theme="light"] .icon-moon{display:block;}
.formation-desc{font-size:12.5px;color:var(--text-dim);line-height:1.5;margin-top:10px;}
/* ---------- Stack & compétences ---------- */
.stack-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(284px,1fr));gap:16px;}
.stack-grid.is-secondary{grid-template-columns:repeat(auto-fit,minmax(260px,1fr));}
.stack-group{border:1px solid var(--border);border-radius:14px;background:var(--surface-2);padding:18px;}
.is-secondary .stack-group{border:1px solid var(--border-soft);background:transparent;}
.stack-group-label{font-family:var(--font-mono);font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em;}
.tiles{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px;}
.is-secondary .tiles{gap:7px;margin-top:13px;}
.tile{display:flex;align-items:center;gap:9px;padding:7px 11px 7px 9px;border:1px solid var(--border);border-radius:10px;background:var(--bg);color:var(--text-muted);}
.is-secondary .tile{gap:8px;padding:6px 10px 6px 8px;border-radius:9px;}
.tile .ic{width:18px;height:18px;}
.is-secondary .tile .ic{width:15px;height:15px;}
/* Masonry en CSS pur : packing vertical (pas de cartes à moitié vides) ;
colonnes auto selon la largeur, comme l'ancien auto-fit. */
.stack-grid{columns:284px;column-gap:16px;}
.stack-group{border:1px solid var(--border);border-radius:14px;background:var(--surface-2);padding:18px;break-inside:avoid;margin-bottom:16px;}
.stack-group-label{display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--font-mono);font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.06em;}
.stack-group-count{font-size:10px;color:var(--text-dim);border:1px solid var(--border);border-radius:999px;padding:1px 7px;}
.tiles{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-top:14px;}
.tile{display:flex;align-items:center;gap:9px;padding:7px 11px 7px 9px;border:1px solid var(--border);border-radius:10px;background:var(--bg);color:var(--text-muted);cursor:default;transition:transform .2s ease,border-color .2s ease,box-shadow .2s ease,background-color .2s ease;}
.tile:hover{transform:translateY(-2px);border-color:var(--accent);background:color-mix(in srgb,var(--brand,var(--accent)) 7%,var(--bg));box-shadow:0 8px 20px -10px color-mix(in srgb,var(--brand,var(--accent)) 55%,transparent);}
.tile .ic{width:18px;height:18px;transition:color .2s ease;}
.tile:hover .ic{color:var(--brand,var(--accent));}
.tile-mono{width:24px;height:24px;border-radius:7px;background:var(--accent-solid);color:#fff;display:flex;align-items:center;justify-content:center;flex:none;font-family:var(--font-mono);font-size:11px;font-weight:600;}
.is-secondary .tile-mono{width:21px;height:21px;border-radius:6px;font-size:10px;}
.tile:hover .tile-mono{background:var(--accent-hover);}
.tile-name{font-family:var(--font-mono);font-size:13px;color:var(--text);white-space:nowrap;}
.is-secondary .tile-name{font-size:12.5px;color:var(--text-muted);}
.soft{margin-top:34px;display:flex;flex-direction:column;gap:16px;}
.soft{margin-top:40px;display:grid;gap:24px;}
@media(min-width:760px){.soft{grid-template-columns:1fr 1fr;gap:32px;}}
.soft-label{font-family:var(--font-mono);font-size:11px;color:var(--text-dim);margin-bottom:11px;}
.pills{display:flex;flex-wrap:wrap;gap:8px;}
.pill{font-size:13px;padding:6px 12px;border:1px solid var(--border);border-radius:999px;color:var(--text-muted);background:var(--surface-2);}
.pill{font-size:13px;padding:6px 12px;border:1px solid var(--border);border-radius:999px;color:var(--text-muted);background:var(--surface-2);transition:transform .2s ease,border-color .2s ease,color .2s ease;}
.pill::before{content:"";display:inline-block;width:5px;height:5px;border-radius:50%;background:var(--accent);margin-right:7px;vertical-align:middle;opacity:.45;transition:opacity .2s ease;}
.pill:hover{border-color:var(--accent);color:var(--text);transform:translateY(-1px);}
.pill:hover::before{opacity:1;}
/* ---------- Centres d'intérêt ---------- */
.interests{display:grid;grid-template-columns:repeat(auto-fit,minmax(216px,1fr));gap:14px;}
@@ -250,7 +252,7 @@ html[data-theme="light"] .icon-moon{display:block;}
/* ---------- Responsive : repli nav < 820px ---------- */
@media (max-width:819px){
.nav-links,.btn-coffee--header,.btn-github--header{display:none;}
.nav-links,.btn-coffee--header{display:none;}
.burger{display:inline-flex;}
}
@media (max-width:560px){