From 3326feecd589198806822c1fb3825d59acb57216 Mon Sep 17 00:00:00 2001 From: Proxyfil Date: Sun, 26 Oct 2025 11:05:57 +0100 Subject: [PATCH 1/3] feat: Added i18n to project and translated in french --- .astro/content.d.ts | 2 +- .astro/data-store.json | 2 +- .astro/settings.json | 2 +- package-lock.json | 116 +++++++++- package.json | 1 + src/components/blog/DatePub.astro | 7 +- src/components/blog/Hero.astro | 16 +- src/components/blog/Languages.astro | 11 +- src/components/blog/LastPost.astro | 16 +- src/components/blog/ListPosts.astro | 16 +- src/components/blog/Tags.astro | 10 +- src/components/layout/Footer.astro | 16 +- src/components/layout/Header.astro | 10 +- src/components/layout/NavArticle.astro | 6 +- src/components/layout/Navigation.astro | 22 +- .../layout/NavigationArticles.astro | 10 +- src/components/portfolio/Contact.astro | 10 +- src/components/portfolio/Experience.astro | 28 ++- src/components/portfolio/HeroIndex.astro | 54 ++--- src/components/portfolio/Hobbies.astro | 16 +- src/components/portfolio/ListProjects.astro | 12 +- src/components/ui/Capsule.astro | 8 +- src/components/ui/LanguagePicker.astro | 24 ++ src/components/ui/ReadMore.astro | 6 +- src/components/ui/Share.astro | 12 +- src/components/ui/Tag.astro | 6 +- src/i18n/ui.ts | 149 +++++++++++++ src/i18n/utils.ts | 16 ++ src/layouts/MarkdownAbout.astro | 22 +- src/pages/{ => en}/about-me.md | 0 src/pages/{ => en}/blog/index.astro | 8 +- .../{ => en}/blog/posts/create-deb-package.md | 0 src/pages/{ => en}/blog/posts/index.astro | 14 +- src/pages/{ => en}/blog/posts/retex-sfh.md | 0 .../{ => en}/blog/posts/retex-zevent2025.md | 0 src/pages/{ => en}/blog/tags/[tag].astro | 6 +- src/pages/{ => en}/blog/tags/index.astro | 0 .../{ => en}/blog/techs/[category].astro | 6 +- src/pages/{ => en}/blog/techs/index.astro | 6 +- src/pages/en/index.astro | 66 ++++++ .../{ => en}/portfolio/projects/ingdoc.md | 0 .../{ => en}/portfolio/projects/pollpy.md | 0 .../portfolio/projects/tungstene_enriched.md | 0 src/pages/{ => en}/robots.txt.ts | 0 src/pages/{ => en}/rss.xml.js | 0 src/pages/fr/about-me.md | 50 +++++ src/pages/fr/blog/index.astro | 22 ++ src/pages/fr/blog/posts/create-deb-package.md | 210 ++++++++++++++++++ src/pages/fr/blog/posts/index.astro | 54 +++++ src/pages/{ => fr}/blog/posts/lettre.md | 0 src/pages/fr/blog/posts/retex-sfh.md | 158 +++++++++++++ src/pages/fr/blog/posts/retex-zevent2025.md | 123 ++++++++++ src/pages/fr/blog/tags/[tag].astro | 41 ++++ src/pages/fr/blog/tags/index.astro | 19 ++ src/pages/fr/blog/techs/[category].astro | 55 +++++ src/pages/fr/blog/techs/index.astro | 20 ++ src/pages/fr/index.astro | 66 ++++++ src/pages/fr/portfolio/projects/ingdoc.md | 43 ++++ src/pages/fr/portfolio/projects/pollpy.md | 31 +++ .../portfolio/projects/tungstene_enriched.md | 42 ++++ src/pages/fr/robots.txt.ts | 15 ++ src/pages/fr/rss.xml.js | 11 + src/pages/index.astro | 65 +----- 63 files changed, 1582 insertions(+), 175 deletions(-) create mode 100644 src/components/ui/LanguagePicker.astro create mode 100644 src/i18n/ui.ts create mode 100644 src/i18n/utils.ts rename src/pages/{ => en}/about-me.md (100%) rename src/pages/{ => en}/blog/index.astro (68%) rename src/pages/{ => en}/blog/posts/create-deb-package.md (100%) rename src/pages/{ => en}/blog/posts/index.astro (79%) rename src/pages/{ => en}/blog/posts/retex-sfh.md (100%) rename src/pages/{ => en}/blog/posts/retex-zevent2025.md (100%) rename src/pages/{ => en}/blog/tags/[tag].astro (85%) rename src/pages/{ => en}/blog/tags/index.astro (100%) rename src/pages/{ => en}/blog/techs/[category].astro (88%) rename src/pages/{ => en}/blog/techs/index.astro (75%) create mode 100644 src/pages/en/index.astro rename src/pages/{ => en}/portfolio/projects/ingdoc.md (100%) rename src/pages/{ => en}/portfolio/projects/pollpy.md (100%) rename src/pages/{ => en}/portfolio/projects/tungstene_enriched.md (100%) rename src/pages/{ => en}/robots.txt.ts (100%) rename src/pages/{ => en}/rss.xml.js (100%) create mode 100644 src/pages/fr/about-me.md create mode 100644 src/pages/fr/blog/index.astro create mode 100644 src/pages/fr/blog/posts/create-deb-package.md create mode 100644 src/pages/fr/blog/posts/index.astro rename src/pages/{ => fr}/blog/posts/lettre.md (100%) create mode 100644 src/pages/fr/blog/posts/retex-sfh.md create mode 100644 src/pages/fr/blog/posts/retex-zevent2025.md create mode 100644 src/pages/fr/blog/tags/[tag].astro create mode 100644 src/pages/fr/blog/tags/index.astro create mode 100644 src/pages/fr/blog/techs/[category].astro create mode 100644 src/pages/fr/blog/techs/index.astro create mode 100644 src/pages/fr/index.astro create mode 100644 src/pages/fr/portfolio/projects/ingdoc.md create mode 100644 src/pages/fr/portfolio/projects/pollpy.md create mode 100644 src/pages/fr/portfolio/projects/tungstene_enriched.md create mode 100644 src/pages/fr/robots.txt.ts create mode 100644 src/pages/fr/rss.xml.js diff --git a/.astro/content.d.ts b/.astro/content.d.ts index c21ccee..48a5228 100644 --- a/.astro/content.d.ts +++ b/.astro/content.d.ts @@ -160,5 +160,5 @@ declare module 'astro:content' { type AnyEntryMap = ContentEntryMap & DataEntryMap; - export type ContentConfig = typeof import("./../src/content/config.js"); + export type ContentConfig = typeof import("../src/content/config.js"); } diff --git a/.astro/data-store.json b/.astro/data-store.json index 0c0b23d..8d9e3b9 100644 --- a/.astro/data-store.json +++ b/.astro/data-store.json @@ -1 +1 @@ -[["Map",1,2,9,10],"meta::meta",["Map",3,4,5,6,7,8],"astro-version","5.8.1","content-config-digest","5e54967a61329eee","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"site\":\"https://proxyfil.fr\",\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"experimentalDefaultStyles\":true},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"responsiveImages\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false},\"legacy\":{\"collections\":false}}","staticData",["Map",11,12],"allStaticData",{"id":11,"data":13,"filePath":37,"digest":38},{"profileImage":14,"profileAlt":15,"profileLink":16,"profileTitle":17,"profileName":18,"github":19,"githubText":20,"portfolioImage":21,"email":22,"linkedin":23,"instagram":24,"youtube":25,"alias":26,"contactSectionTitle":27,"contactSectionSubtitle":28,"contactSectionButtonText":29,"contactSectionButtonIcon":30,"techsTitle":31,"instagramIconName":32,"youtubeIconName":33,"githubIconName":34,"linkedinIconName":35,"emailIconName":36},"/images/proxyfil.webp","Photo of Pierre-Louis Leclerc (Proxyfil) for the blog","/about-me","DevOps Engineer Student","Pierre-Louis Leclerc","https://github.com/proxyfil","Want to see my code?","/images/portfolio.webp","leclerc.pierre-louis@gmail.com","https://www.linkedin.com/in/pierre-louis-leclerc/","https://www.instagram.com/pierrelouisirl/","https://www.youtube.com/@proxyfil4036","Proxyfil","Ready to take your idea to the next level?","Let's work together.","Contact Me","paperplane","TECHS","instagram","youtube","github","linkedin","envelope","src/content/staticData/allStaticData.json","606acb4ab07cd770"] \ No newline at end of file +[["Map",1,2,9,10],"meta::meta",["Map",3,4,5,6,7,8],"astro-version","5.8.1","content-config-digest","3120b7a6e0893304","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"site\":\"https://proxyfil.fr\",\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"experimentalDefaultStyles\":true},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"responsiveImages\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false},\"legacy\":{\"collections\":false}}","staticData",["Map",11,12],"allStaticData",{"id":11,"data":13,"filePath":37,"digest":38},{"profileImage":14,"profileAlt":15,"profileLink":16,"profileTitle":17,"profileName":18,"github":19,"githubText":20,"portfolioImage":21,"email":22,"linkedin":23,"instagram":24,"youtube":25,"alias":26,"contactSectionTitle":27,"contactSectionSubtitle":28,"contactSectionButtonText":29,"contactSectionButtonIcon":30,"techsTitle":31,"instagramIconName":32,"youtubeIconName":33,"githubIconName":34,"linkedinIconName":35,"emailIconName":36},"/images/proxyfil.webp","Photo of Pierre-Louis Leclerc (Proxyfil) for the blog","/about-me","DevOps Engineer Student","Pierre-Louis Leclerc","https://github.com/proxyfil","Want to see my code?","/images/portfolio.webp","leclerc.pierre-louis@gmail.com","https://www.linkedin.com/in/pierre-louis-leclerc/","https://www.instagram.com/pierrelouisirl/","https://www.youtube.com/@proxyfil4036","Proxyfil","Ready to take your idea to the next level?","Let's work together.","Contact Me","paperplane","TECHS","instagram","youtube","github","linkedin","envelope","src/content/staticData/allStaticData.json","67d2708f51b24e1c"] \ No newline at end of file diff --git a/.astro/settings.json b/.astro/settings.json index edc9747..8115b4c 100644 --- a/.astro/settings.json +++ b/.astro/settings.json @@ -1,5 +1,5 @@ { "_variables": { - "lastUpdateCheck": 1748972451562 + "lastUpdateCheck": 1761163064283 } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index fe9102a..fe52502 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "alpinejs": "^3.14.9", "astro": "^5.6.1", "astro-icon": "^1.1.5", + "i18n": "^0.15.2", "preact": "^10.26.2", "prismjs": "^1.30.0", "tailwindcss": "^4.0.8" @@ -1704,6 +1705,50 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@messageformat/core": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@messageformat/core/-/core-3.4.0.tgz", + "integrity": "sha512-NgCFubFFIdMWJGN5WuQhHCNmzk7QgiVfrViFxcS99j7F5dDS5EP6raR54I+2ydhe4+5/XTn/YIEppFaqqVWHsw==", + "license": "MIT", + "dependencies": { + "@messageformat/date-skeleton": "^1.0.0", + "@messageformat/number-skeleton": "^1.0.0", + "@messageformat/parser": "^5.1.0", + "@messageformat/runtime": "^3.0.1", + "make-plural": "^7.0.0", + "safe-identifier": "^0.4.1" + } + }, + "node_modules/@messageformat/date-skeleton": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@messageformat/date-skeleton/-/date-skeleton-1.1.0.tgz", + "integrity": "sha512-rmGAfB1tIPER+gh3p/RgA+PVeRE/gxuQ2w4snFWPF5xtb5mbWR7Cbw7wCOftcUypbD6HVoxrVdyyghPm3WzP5A==", + "license": "MIT" + }, + "node_modules/@messageformat/number-skeleton": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@messageformat/number-skeleton/-/number-skeleton-1.2.0.tgz", + "integrity": "sha512-xsgwcL7J7WhlHJ3RNbaVgssaIwcEyFkBqxHdcdaiJzwTZAWEOD8BuUFxnxV9k5S0qHN3v/KzUpq0IUpjH1seRg==", + "license": "MIT" + }, + "node_modules/@messageformat/parser": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@messageformat/parser/-/parser-5.1.1.tgz", + "integrity": "sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==", + "license": "MIT", + "dependencies": { + "moo": "^0.5.1" + } + }, + "node_modules/@messageformat/runtime": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.1.tgz", + "integrity": "sha512-6RU5ol2lDtO8bD9Yxe6CZkl0DArdv0qkuoZC+ZwowU+cdRlVE1157wjCmlA5Rsf1Xc/brACnsZa5PZpEDfTFFg==", + "license": "MIT", + "dependencies": { + "make-plural": "^7.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3727,9 +3772,9 @@ "license": "CC0-1.0" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4454,6 +4499,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-printf": { + "version": "1.6.10", + "resolved": "https://registry.npmjs.org/fast-printf/-/fast-printf-1.6.10.tgz", + "integrity": "sha512-GwTgG9O4FVIdShhbVF3JxOgSBY2+ePGsu2V/UONgoCPzF9VY6ZdBMKsHKCYQHZwNk3qNouUolRDsgVxcVA5G1w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=10.0" + } + }, "node_modules/fast-xml-parser": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", @@ -5101,6 +5155,26 @@ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, + "node_modules/i18n": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/i18n/-/i18n-0.15.2.tgz", + "integrity": "sha512-mdBxCfC651UL/hNizIQgB1NHwbBKjlrPcsoTzd/X8rNbJlS1FMF//TOyHEVFg9Dxo0RcrI2ZKt1AFTNe3Q40og==", + "license": "MIT", + "dependencies": { + "@messageformat/core": "^3.4.0", + "debug": "^4.4.3", + "fast-printf": "^1.6.10", + "make-plural": "^7.4.0", + "math-interval-parser": "^2.0.1", + "mustache": "^4.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/mashpie" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -5702,6 +5776,12 @@ "source-map-js": "^1.2.0" } }, + "node_modules/make-plural": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/make-plural/-/make-plural-7.4.0.tgz", + "integrity": "sha512-4/gC9KVNTV6pvYg2gFeQYTW3mWaoJt7WZE5vrp1KnQDgW92JtYZnzmZT81oj/dUTqAIu0ufI2x3dkgu3bB1tYg==", + "license": "Unicode-DFS-2016" + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -5712,6 +5792,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/math-interval-parser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/math-interval-parser/-/math-interval-parser-2.0.1.tgz", + "integrity": "sha512-VmlAmb0UJwlvMyx8iPhXUDnVW1F9IrGEd9CIOmv+XL8AErCUUuozoDMrgImvnYt2A+53qVX/tPW6YJurMKYsvA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6648,6 +6737,12 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", + "license": "BSD-3-Clause" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -6663,6 +6758,15 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -7598,6 +7702,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-identifier": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/safe-identifier/-/safe-identifier-0.4.2.tgz", + "integrity": "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==", + "license": "ISC" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", diff --git a/package.json b/package.json index 54e3c7a..49088bc 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "alpinejs": "^3.14.9", "astro": "^5.6.1", "astro-icon": "^1.1.5", + "i18n": "^0.15.2", "preact": "^10.26.2", "prismjs": "^1.30.0", "tailwindcss": "^4.0.8" diff --git a/src/components/blog/DatePub.astro b/src/components/blog/DatePub.astro index 15f79db..c8998db 100644 --- a/src/components/blog/DatePub.astro +++ b/src/components/blog/DatePub.astro @@ -1,5 +1,10 @@ --- +import { getLangFromUrl, useTranslations } from "../../i18n/utils"; +const lang = getLangFromUrl(Astro.url); + const {date, class: className} = Astro.props; + +let localeDate = lang === 'fr' ? 'fr-FR' : 'en-US'; --- { - new Date(date).toLocaleDateString("en-US", { + new Date(date).toLocaleDateString(localeDate, { timeZone: "UTC", // Avoid timezone adjustments day: "numeric", month: "long", diff --git a/src/components/blog/Hero.astro b/src/components/blog/Hero.astro index 3fe4fc7..ffa1173 100644 --- a/src/components/blog/Hero.astro +++ b/src/components/blog/Hero.astro @@ -6,6 +6,10 @@ import { Icon } from "astro-icon/components"; import Heading from "../ui/Heading.astro"; import { AstroError } from "astro/errors"; import { getCollection} from "astro:content"; +import { getLangFromUrl, useTranslations } from "../../i18n/utils"; + +const lang = getLangFromUrl(Astro.url); +const t = useTranslations(lang); const [staticData] = await getCollection('staticData'); @@ -42,7 +46,7 @@ if (!staticData) { >
@@ -68,11 +72,11 @@ if (!staticData) { class="group-hover:scale-103 ease-in-out duration-500 w-5/12 max-sm:w-auto flex justify-center items-center" >
- +
@@ -80,7 +84,7 @@ if (!staticData) { class="font-extrabold text-lg max-xl:text-base max-lg:text-sm max-lg:flex max-lg:flex-col-reverse max-md:flex-row leading-normal max-sm:leading-none" >{staticData.data.profileTitle}{t('staticData.data.profileTitle')} 🚀
diff --git a/src/components/portfolio/Experience.astro b/src/components/portfolio/Experience.astro index b0c8ff9..fb0b026 100644 --- a/src/components/portfolio/Experience.astro +++ b/src/components/portfolio/Experience.astro @@ -1,6 +1,11 @@ --- import ExperienceItem from "./ExperienceItem.astro"; -const EXPERIENCE = [ +import { getLangFromUrl, useTranslations } from "../../i18n/utils"; + +const lang = getLangFromUrl(Astro.url); +const t = useTranslations(lang); + +let EXPERIENCE = [ { date: "October 2024 - Present", title: "DevOps Engineering Student", @@ -15,8 +20,27 @@ const EXPERIENCE = [ description: "I worked as a commercial advisor and managed the computer park at Virtual Xperience, a company specializing in virtual reality experiences. My role involved assisting customers, managing user experience, and ensuring the smooth operation of the computer systems.", }, - ]; + +if(lang == 'fr') { + EXPERIENCE = [ + { + date: "Octobre 2024 - Présent", + title: "Apprenti Ingénieur DevOps", + company: "CIRAD / Polytech Montpellier", + description: + "En tant qu'apprenti Ingénieur DevOps, je suis impliqué dans la conception, la création et le déploiement de solutions pour les chercheurs du CIRAD de Montpellier. J'étudie en parallèle à Polytech Montpellier, où j'apprends à appliquer les principes DevOps pour améliorer le développement d'applications et leur déploiement.", + }, + { + date: "Août 2023", + title: "Conseiller commercial et gestionnaire du parc informatique", + company: "Virtual Xperience", + description: + "J'ai travaillé en tant que conseiller commercial et ai géré le parc informatique chez Virtual Xperience, une entreprise spécialisée dans les expériences de réalité virtuelle. Mon rôle consistait à assister les clients, gérer l'expérience utilisateur et assurer le bon fonctionnement des systèmes informatiques.", + }, + + ]; +} --- @@ -57,35 +61,35 @@ if (!staticData) { - Available for work + {t('heroindex.availableForWork')}

- {staticData.data.profileName} 👋 + {t('staticData.data.profileName')} 👋

{staticData.data.profileTitle} with {t('staticData.data.profileTitle')} {t('heroindex.introduction.with')} 3 years of experience, passionate about devops, new technologies, and coffee that sparks ideas. Let's try to create something unbelievable ! 🚀✨ + >{t('heroindex.introduction.profileExperience')}{t('heroindex.introduction.end')}

diff --git a/src/components/ui/Capsule.astro b/src/components/ui/Capsule.astro index 1e8656b..9c66ce6 100644 --- a/src/components/ui/Capsule.astro +++ b/src/components/ui/Capsule.astro @@ -1,6 +1,10 @@ --- import { Icon } from "astro-icon/components"; import { getLanguage } from "../../utils/languages"; +import { getLangFromUrl, useTranslations } from "../../i18n/utils"; + +const siteLang = getLangFromUrl(Astro.url); +const t = useTranslations(siteLang); interface Props { lang: string; @@ -49,8 +53,8 @@ const getIconContainerClasses = () => { linkEnabled ? ( + {Object.entries(languages).map(([langCode, label]) => ( + + ))} + + + \ No newline at end of file diff --git a/src/components/ui/ReadMore.astro b/src/components/ui/ReadMore.astro index c0f0ef2..582a6ac 100644 --- a/src/components/ui/ReadMore.astro +++ b/src/components/ui/ReadMore.astro @@ -1,10 +1,14 @@ --- const {class: className} = Astro.props; import { Icon } from "astro-icon/components"; +import { getLangFromUrl, useTranslations } from "../../i18n/utils"; + +const lang = getLangFromUrl(Astro.url); +const t = useTranslations(lang); ---
- Read more + {t('readMore')}
diff --git a/src/components/ui/Share.astro b/src/components/ui/Share.astro index 4d4d343..91c3c3e 100644 --- a/src/components/ui/Share.astro +++ b/src/components/ui/Share.astro @@ -1,5 +1,9 @@ --- import { Icon } from "astro-icon/components"; +import { getLangFromUrl, useTranslations } from "../../i18n/utils"; + +const lang = getLangFromUrl(Astro.url); +const t = useTranslations(lang); const { tweetText, currentUrl } = Astro.props; @@ -22,8 +26,8 @@ const shareLinks = [ ]; --- -
- Share +
+ {t('share')} { shareLinks.map((link) => ( diff --git a/src/components/ui/Tag.astro b/src/components/ui/Tag.astro index 24335f0..6f3e340 100644 --- a/src/components/ui/Tag.astro +++ b/src/components/ui/Tag.astro @@ -1,9 +1,13 @@ --- const { tag, forceDark = false } = Astro.props; +import { getLangFromUrl, useTranslations } from "../../i18n/utils"; + +const lang = getLangFromUrl(Astro.url); +const t = useTranslations(lang); --- {tag} diff --git a/src/i18n/ui.ts b/src/i18n/ui.ts new file mode 100644 index 0000000..9a7f3e7 --- /dev/null +++ b/src/i18n/ui.ts @@ -0,0 +1,149 @@ +import { getCollection } from "astro:content"; +import { AstroError } from "astro/errors"; + +const [staticData] = await getCollection('staticData'); + +if (!staticData) { + throw new AstroError("JSON data not found"); +} + +export const languages = { + en: '🇬🇧​', + fr: '🇫🇷​', +}; + +export const defaultLang = 'en'; + +export const ui = { + en: { + 'nav.home': 'Home', + 'nav.about': 'About', + 'nav.twitter': 'Twitter', + 'blog.hero.aboutMeButton': 'About Me', + 'blog.lastPost.languages': 'Programming languages', + 'blog.lastPost.readArticle': 'Read article', + 'blog.lastPost.tags': 'Article tags', + 'blog.listPosts.morePosts': 'More posts', + 'blog.listPosts.all': 'All', + 'blog.listPosts.posts': 'Posts', + 'footer.developedBy': `Developed by ${staticData.data.alias} with Astro`, + 'footer.contact.sendEmail': `Send email to ${staticData.data.email}`, + 'footer.contact.visitInstagram': `Visit ${staticData.data.alias} on Instagram`, + 'footer.contact.visitYouTube': `Visit ${staticData.data.alias} on YouTube`, + 'footer.contact.visitGitHub': `Visit ${staticData.data.alias} on GitHub`, + 'footer.contact.visitLinkedIn': `Visit ${staticData.data.alias} on LinkedIn`, + 'header.nav.toggleMenu': 'Toggle menu', + 'header.nav.home': 'Home', + 'article.nav.tableOfContents': 'Table of Contents', + 'nav.blog': 'Blog', + 'nav.blog.allPosts': 'All Posts', + 'nav.experience': 'Experience', + 'nav.projects': 'Projects', + 'nav.main': 'Main Navigation', + 'nav.socialLinks': 'Social Media Links', + 'staticData.data.githubText': 'Want to see my code ?', + 'staticData.data.profileAlt': 'Profile picture of Pierre-Louis Leclerc (Proxyfil)', + 'staticData.data.profileTitle': 'Profile of Pierre-Louis Leclerc (Proxyfil)', + 'staticData.data.alias': staticData.data.alias, + 'staticData.data.contactSectionSubtitle.work': 'Let\'s work', + 'staticData.data.contactSectionSubtitle.together': 'together.', + 'staticData.data.contactSectionButtonText': 'Contact Me', + 'staticData.data.profileName': staticData.data.profileName, + 'heroindex.sectionAriaLabel': 'Professional profile and introduction', + 'heroindex.aboutme': 'View about me page', + 'heroindex.availableForWork': 'Available for work', + 'heroindex.introduction.profileExperience': '3 years of experience', + 'heroindex.introduction.with': 'with', + 'heroindex.introduction.end': ', passionate about devops, new technologies, and coffee that sparks ideas. Let\'s try to create something unbelievable ! 🚀✨', + 'heroindex.button.aboutme': 'About Me', + 'heroindex.viewMyGitHubProfile': 'View this site\'s code repository on GitHub', + 'heroindex.seeMyWork': 'Want to see my work ?', + 'heroindex.viewProjectsSection': 'View projects section', + 'heroindex.project.introduction.1': 'I love', + 'heroindex.project.introduction.2': 'turning ideas into real projects.', + 'heroindex.project.introduction.3': 'Here I show you some of the', + 'heroindex.project.introduction.4': 'developments', + 'heroindex.project.introduction.5': 'I\'ve worked on, applying technology, design, and lots of creativity.', + 'heroindex.project.introduction.6': 'Check them out !', + 'heroindex.project.title': 'Projects', + 'heroindex.beyondTheCode': 'Beyond the Code', + 'listProjects.viewMoreButton': 'View More Projects...', + 'readMore': 'Read more', + 'share': 'Share', + 'share.ariaLabel': 'Share on social media', + 'share.shareOn': 'Share on', + 'capsule.viewPostsAbout': 'View posts about', + 'about.profileTitle': 'DevOps Engineer Apprentice', + 'about.title': 'About', + 'about.titleGradient': 'me', + 'about.experience.experience': 'Experience', + 'about.experience.experienceGradient': 'Work', + 'about.stack.title': 'Stack', + 'about.stack.titleGradient': 'Technological' + }, + fr: { + 'nav.home': 'Accueil', + 'nav.about': 'À propos', + 'blog.hero.aboutMeButton': 'À propos de moi', + 'blog.lastPost.languages': 'Langages de programmation', + 'blog.lastPost.readArticle': 'Lire l\'article', + 'blog.lastPost.tags': 'Tags de l\'article', + 'blog.listPosts.morePosts': 'Plus d\'articles', + 'blog.listPosts.all': 'Tous les', + 'blog.listPosts.posts': 'Posts', + 'footer.developedBy': `Développé par ${staticData.data.alias} avec Astro`, + 'footer.contact.sendEmail': `Envoyer un email à ${staticData.data.email}`, + 'footer.contact.visitInstagram': `Regardez ${staticData.data.alias} sur Instagram`, + 'footer.contact.visitYouTube': `Regardez ${staticData.data.alias} sur YouTube`, + 'footer.contact.visitGitHub': `Regardez ${staticData.data.alias} sur GitHub`, + 'footer.contact.visitLinkedIn': `Regardez ${staticData.data.alias} sur LinkedIn`, + 'header.nav.toggleMenu': 'Basculer le menu', + 'header.nav.home': 'Accueil', + 'article.nav.tableOfContents': 'Table des matières', + 'nav.blog': 'Blog', + 'nav.blog.allPosts': 'Tous les articles', + 'nav.experience': 'Expérience', + 'nav.projects': 'Projets', + 'nav.main': 'Navigation principale', + 'nav.socialLinks': 'Liens vers les réseaux sociaux', + 'staticData.data.githubText': 'Envie de voir mon code ?', + 'staticData.data.profileAlt': 'Photo de profil de Pierre-Louis Leclerc (Proxyfil)', + 'staticData.data.profileTitle': 'Profil de Pierre-Louis Leclerc (Proxyfil)', + 'staticData.data.alias': staticData.data.alias, + 'staticData.data.contactSectionSubtitle.work': 'Travaillons', + 'staticData.data.contactSectionSubtitle.together': 'ensemble.', + 'staticData.data.contactSectionButtonText': 'Contactez-moi', + 'staticData.data.profileName': staticData.data.profileName, + 'heroindex.sectionAriaLabel': 'Profil professionnel et introduction', + 'heroindex.aboutme': 'Voir la page à propos de moi', + 'heroindex.availableForWork': 'Disponible pour des missions', + 'heroindex.introduction.profileExperience': '3 ans d\'expérience', + 'heroindex.introduction.with': 'avec', + 'heroindex.introduction.end': ', passionné par le devops, les nouvelles technologies, et le café qui stimule les idées. Essayons de créer quelque chose d\'incroyable ! 🚀✨', + 'heroindex.button.aboutme': 'À propos de moi', + 'heroindex.viewMyGitHubProfile': 'Voir le dépôt de code de ce site sur GitHub', + 'heroindex.seeMyWork': 'Envie de voir mon travail ?', + 'heroindex.viewProjectsSection': 'Voir la section projets', + 'heroindex.project.introduction.1': 'J\'adore', + 'heroindex.project.introduction.2': 'transformer des idées en projets réels.', + 'heroindex.project.introduction.3': 'Ici, je vous montre quelques-uns des', + 'heroindex.project.introduction.4': 'projets', + 'heroindex.project.introduction.5': 'sur lesquels j\'ai travaillé, en appliquant des technologies, du design, et beaucoup de créativité.', + 'heroindex.project.introduction.6': 'Découvrez-les !', + 'heroindex.project.title': 'Projets', + 'heroindex.beyondTheCode': 'Au-delà du code', + 'listProjects.viewMoreButton': 'Voir plus de projets...', + 'readMore': 'Lire la suite', + 'share': 'Partager', + 'share.ariaLabel': 'Partager sur les réseaux sociaux', + 'share.shareOn': 'Partager sur', + 'capsule.viewPostsAbout': 'Voir les posts à propos de', + 'about.profileTitle': 'Apprenti Ingénieur DevOps', + 'about.title': 'À propos', + 'about.titleGradient': 'de moi', + 'about.experience.experience': 'Expérience', + 'about.experience.experienceGradient': 'Professionnelle', + 'about.stack.title': 'Stack', + 'about.stack.titleGradient': 'Technologique' + }, +} as const; \ No newline at end of file diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts new file mode 100644 index 0000000..be7c558 --- /dev/null +++ b/src/i18n/utils.ts @@ -0,0 +1,16 @@ +import type { string } from 'astro:schema'; +import { ui, defaultLang } from './ui'; + +export function getLangFromUrl(url: URL) { + const [, lang]: string[] = url.pathname.split('/'); + if (lang in ui) return lang as keyof typeof ui; + return defaultLang; +} + +export function useTranslations(lang: keyof typeof ui) { + return function t(key: keyof typeof ui[typeof defaultLang]) { + const translations = ui[lang] as Record; + const fallback = ui[defaultLang] as Record; + return translations[key as string] ?? fallback[key as string]; + } +} \ No newline at end of file diff --git a/src/layouts/MarkdownAbout.astro b/src/layouts/MarkdownAbout.astro index 5060ccd..9b6fed2 100644 --- a/src/layouts/MarkdownAbout.astro +++ b/src/layouts/MarkdownAbout.astro @@ -7,6 +7,11 @@ import NavArticle from "../components/layout/NavArticle.astro"; import Contact from "../components/portfolio/Contact.astro"; import Tools from "../components/portfolio/Tools.astro"; import { getCollection} from "astro:content"; +import { getLangFromUrl, useTranslations } from "../i18n/utils"; + +const lang = getLangFromUrl(Astro.url); +const t = useTranslations(lang); + const { frontmatter } = Astro.props; const [staticData] = await getCollection('staticData'); --- @@ -50,7 +55,7 @@ const [staticData] = await getCollection('staticData'); aria-label="Photo of Pierre-Louis Leclerc (Proxyfil) for the blog" >
@@ -68,18 +73,17 @@ const [staticData] = await getCollection('staticData'); > {staticData.data.profileTitle} with {t('about.profileTitle')} {t('heroindex.introduction.with')} 3 years of experience, passionate about devops, new technologies, and coffee that - sparks ideas. Let's try to create something unbelievable ! 🚀✨ + >{t('heroindex.introduction.profileExperience')}{t('heroindex.introduction.end')}

- +
@@ -92,7 +96,7 @@ const [staticData] = await getCollection('staticData');
- +
@@ -102,7 +106,7 @@ const [staticData] = await getCollection('staticData');
- +
diff --git a/src/pages/about-me.md b/src/pages/en/about-me.md similarity index 100% rename from src/pages/about-me.md rename to src/pages/en/about-me.md diff --git a/src/pages/blog/index.astro b/src/pages/en/blog/index.astro similarity index 68% rename from src/pages/blog/index.astro rename to src/pages/en/blog/index.astro index e40ecce..9e6e310 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/en/blog/index.astro @@ -1,8 +1,8 @@ --- -import Layout from "../../layouts/Layout.astro"; -import Hero from "../../components/blog/Hero.astro"; -import Tags from "../../components/blog/Tags.astro"; -import ListPosts from "../../components/blog/ListPosts.astro"; +import Layout from "../../../layouts/Layout.astro"; +import Hero from "../../../components/blog/Hero.astro"; +import Tags from "../../../components/blog/Tags.astro"; +import ListPosts from "../../../components/blog/ListPosts.astro"; const pageTitle = "Blog - Pierre-Louis Leclerc | Proxyfil"; const description = "Everything I want to share as a blog."; const ogimage ={ diff --git a/src/pages/blog/posts/create-deb-package.md b/src/pages/en/blog/posts/create-deb-package.md similarity index 100% rename from src/pages/blog/posts/create-deb-package.md rename to src/pages/en/blog/posts/create-deb-package.md diff --git a/src/pages/blog/posts/index.astro b/src/pages/en/blog/posts/index.astro similarity index 79% rename from src/pages/blog/posts/index.astro rename to src/pages/en/blog/posts/index.astro index d73ae37..a1a7410 100644 --- a/src/pages/blog/posts/index.astro +++ b/src/pages/en/blog/posts/index.astro @@ -1,8 +1,8 @@ --- -import Layout from "../../../layouts/Layout.astro"; -import Hero from "../../../components/blog/Hero.astro"; -import Tags from "../../../components/blog/Tags.astro"; -import ListPosts from "../../../components/blog/ListPosts.astro"; +import Layout from "../../../../layouts/Layout.astro"; +import Hero from "../../../../components/blog/Hero.astro"; +import Tags from "../../../../components/blog/Tags.astro"; +import ListPosts from "../../../../components/blog/ListPosts.astro"; const pageTitle = "Blog - Pierre-Louis Leclerc | Proxyfil"; const description = "Everything I want to share as a blog."; const ogimage ={ @@ -11,10 +11,10 @@ const ogimage ={ }; const currentUrl = `${Astro.site}${Astro.url.pathname}`; const tweetText = encodeURIComponent(`"${pageTitle}"`); -import Heading from "../../../components/ui/Heading.astro"; -import Share from "../../../components/ui/Share.astro"; +import Heading from "../../../../components/ui/Heading.astro"; +import Share from "../../../../components/ui/Share.astro"; -import Languages from "../../../components/blog/Languages.astro"; +import Languages from "../../../../components/blog/Languages.astro"; --- diff --git a/src/pages/blog/posts/retex-sfh.md b/src/pages/en/blog/posts/retex-sfh.md similarity index 100% rename from src/pages/blog/posts/retex-sfh.md rename to src/pages/en/blog/posts/retex-sfh.md diff --git a/src/pages/blog/posts/retex-zevent2025.md b/src/pages/en/blog/posts/retex-zevent2025.md similarity index 100% rename from src/pages/blog/posts/retex-zevent2025.md rename to src/pages/en/blog/posts/retex-zevent2025.md diff --git a/src/pages/blog/tags/[tag].astro b/src/pages/en/blog/tags/[tag].astro similarity index 85% rename from src/pages/blog/tags/[tag].astro rename to src/pages/en/blog/tags/[tag].astro index 9f61377..40db3db 100644 --- a/src/pages/blog/tags/[tag].astro +++ b/src/pages/en/blog/tags/[tag].astro @@ -1,7 +1,7 @@ --- -import Layout from '../../../layouts/Layout.astro'; -import BlogPost from '../../../components/blog/BlogPost.astro'; -import Heading from '../../../components/ui/Heading.astro'; +import Layout from '../../../../layouts/Layout.astro'; +import BlogPost from '../../../../components/blog/BlogPost.astro'; +import Heading from '../../../../components/ui/Heading.astro'; export async function getStaticPaths() { const allPosts: any[] = await Astro.glob("../posts/*.md"); diff --git a/src/pages/blog/tags/index.astro b/src/pages/en/blog/tags/index.astro similarity index 100% rename from src/pages/blog/tags/index.astro rename to src/pages/en/blog/tags/index.astro diff --git a/src/pages/blog/techs/[category].astro b/src/pages/en/blog/techs/[category].astro similarity index 88% rename from src/pages/blog/techs/[category].astro rename to src/pages/en/blog/techs/[category].astro index a721b29..3a8227a 100644 --- a/src/pages/blog/techs/[category].astro +++ b/src/pages/en/blog/techs/[category].astro @@ -1,7 +1,7 @@ --- -import Layout from "../../../layouts/Layout.astro"; -import BlogPost from "../../../components/blog/BlogPost.astro"; -import Heading from "../../../components/ui/Heading.astro"; +import Layout from "../../../../layouts/Layout.astro"; +import BlogPost from "../../../../components/blog/BlogPost.astro"; +import Heading from "../../../../components/ui/Heading.astro"; import type { MarkdownInstance } from 'astro'; interface Frontmatter { diff --git a/src/pages/blog/techs/index.astro b/src/pages/en/blog/techs/index.astro similarity index 75% rename from src/pages/blog/techs/index.astro rename to src/pages/en/blog/techs/index.astro index 290f00a..46adad9 100644 --- a/src/pages/blog/techs/index.astro +++ b/src/pages/en/blog/techs/index.astro @@ -1,7 +1,7 @@ --- -import Layout from "../../../layouts/Layout.astro"; -import Languages from "../../../components/blog/Languages.astro"; -import Heading from "../../../components/ui/Heading.astro"; +import Layout from "../../../../layouts/Layout.astro"; +import Languages from "../../../../components/blog/Languages.astro"; +import Heading from "../../../../components/ui/Heading.astro"; const pageTitle = "Technologies"; // Fixed: Title was "Languages" but heading shows "Technologies" const allPosts = await Astro.glob("../posts/*.md"); diff --git a/src/pages/en/index.astro b/src/pages/en/index.astro new file mode 100644 index 0000000..f32bb65 --- /dev/null +++ b/src/pages/en/index.astro @@ -0,0 +1,66 @@ +--- +import Layout from "../../layouts/Layout.astro"; +import Experience from "../../components/portfolio/Experience.astro"; +import HeroIndex from "../../components/portfolio/HeroIndex.astro"; +import Contact from "../../components/portfolio/Contact.astro"; +import ListProjects from "../../components/portfolio/ListProjects.astro"; +import { Icon } from "astro-icon/components"; +const pageTitle = "Portfolio - Pierre-Louis Leclerc | Proxyfil"; +const description = "Everything you need to know about my portfolio as a DevOps engineer student. Discover my experience, projects, and skills in development and technology."; +const ogimage = { + url: "/images/imagedefault.webp", + alt: "Screenshot of a web portfolio with a modern and dark design. Featuring Pierre-Louis Leclerc, a DevOps engineer student with 3 years of experience, highlighting his passion for development and technology. Includes contact sections, projects, and a technology stack with technologies like HTML5, JavaScript, TypeScript, Angular, Node.js, CSS, Tailwind, and more.", +}; +import Heading from "../../components/ui/Heading.astro"; +--- + + + + +
+
+
+ + +
+ + +
+
+ +
+
+ +
+ + +
+

+ I love turning ideas into real projects. +
Here I show you some of the developments I've worked on, applying technology, design, and lots of creativity. + Check them out! +

+
+
+
+ + + +
diff --git a/src/pages/portfolio/projects/ingdoc.md b/src/pages/en/portfolio/projects/ingdoc.md similarity index 100% rename from src/pages/portfolio/projects/ingdoc.md rename to src/pages/en/portfolio/projects/ingdoc.md diff --git a/src/pages/portfolio/projects/pollpy.md b/src/pages/en/portfolio/projects/pollpy.md similarity index 100% rename from src/pages/portfolio/projects/pollpy.md rename to src/pages/en/portfolio/projects/pollpy.md diff --git a/src/pages/portfolio/projects/tungstene_enriched.md b/src/pages/en/portfolio/projects/tungstene_enriched.md similarity index 100% rename from src/pages/portfolio/projects/tungstene_enriched.md rename to src/pages/en/portfolio/projects/tungstene_enriched.md diff --git a/src/pages/robots.txt.ts b/src/pages/en/robots.txt.ts similarity index 100% rename from src/pages/robots.txt.ts rename to src/pages/en/robots.txt.ts diff --git a/src/pages/rss.xml.js b/src/pages/en/rss.xml.js similarity index 100% rename from src/pages/rss.xml.js rename to src/pages/en/rss.xml.js diff --git a/src/pages/fr/about-me.md b/src/pages/fr/about-me.md new file mode 100644 index 0000000..5e718a4 --- /dev/null +++ b/src/pages/fr/about-me.md @@ -0,0 +1,50 @@ +--- +layout: /src/layouts/MarkdownAbout.astro +title: "A propos - Pierre-Louis Leclerc | Proxyfil" +description: "Étudiant en DevOps et passionné de données. De mes débuts en développement à la création de communautés et de projets impactants, ici je partage mes erreurs, expériences et apprentissages. 🚀⭐" +author: "Pierre-Louis Leclerc" +image: + url: "/images/proxyfil.webp" + alt: "Photo de Pierre-Louis Leclerc (Proxyfil) pour le blog" +--- + +## Comment j'ai commencé à apprendre le DevOps 🚀 + +J'ai commencé à créer mes premiers projets sérieux en 2021. Autant que je me souvienne, j'ai toujours été hypnotisé par les ordinateurs et leur magie. J'ai appris à utiliser Internet très tôt et j'ai perdu des tonnes d'heures sur **Minecraft** ou **Factorio** depuis l'âge de 12 ans. Les années ont passé et j'ai découvert les premiers mods ainsi que la programmation avec certains jeux auxquels j'ai joué pendant des années. Ce type primitif de programmation avec Lua et des fichiers yaml a suscité une certaine curiosité dans mon esprit quant à la création de choses avec du code. + +Quand j'étais au collège, j'ai appris HTML/CSS et j'ai commencé à créer de petits sites web. J'ai aussi acheté un livre sur les bases du C et j'étais terrible à ça parce que c'était beaucoup trop difficile pour moi, mais je m'amusais bien avec. + +## Premiers pas sérieux 🖥️ + +En 2019, je suis allé dans un lycée avec une classe d'informatique et d'ingénierie. J'ai commencé à apprendre les bases de l'informatique avec mes premiers langages comme **Python** et **JavaScript**. +Puis les années ont passé et en projet j'ai créé des sites web entiers comme des forums en **PHP** et mes premiers bots Twitch pour faire de l'analyse de données. Avec **Python**, j'ai appris la POO et créé mon premier jeu : un petit "**puissance 4**". +Pour ma 2ème année de lycée, j'ai également créé un petit jeu textuel avec des choix sur ma calculatrice. C'était plutôt amusant même si ce n'était pas vraiment compliqué. + +## Explorer Twitch et les communautés 🤖 + +En 2022, j'ai dû créer mon premier gros projet pour mes cours d'informatique. J'ai choisi de créer un outil pour récupérer des données de Twitch et en faire des statistiques et des analyses. Il a d'abord été construit en **Python** avec des **fichiers JSON**, mais j'ai rapidement changé pour **NodeJS** et une base de données **Postgresql**. +Aujourd'hui, ce projet appelé "Tungstene Enriched" fonctionne encore de temps en temps quand je veux l'utiliser. En 2022, c'était vraiment petit, mais de nos jours, c'est beaucoup plus gros et j'ai ajouté des fonctionnalités au fil des ans, comme la surveillance des données avec **Grafana** ou des processus et publications automatiques. + +La dernière fois que ce projet a été utilisé, c'était ici : zlan2025.gdoc.fr +Si vous voulez plus d'informations sur ce projet, consultez ce fil : Fil "Comment (ne pas) monter une infra" + +## Apprenons le DevOps 💡 + +En 2024, j'ai déménagé de ma petite ville à Montpellier en France. Là, j'ai rejoint une **école d'ingénierie** pour apprendre le DevOps et augmenter mes connaissances globales en informatique. +Après quelques mois, j'ai acquis beaucoup plus de connaissances que dans toute ma vie, exploré de nombreuses technologies et paradigmes. Je pense également que l'apprentissage à temps partiel au sein du CIRAD, qui est une entreprise travaillant sur des sujets **écologiques** et **d'agriculture durable**, m'a beaucoup aidé. +Je suis chargé de créer de nouveaux outils pour les scientifiques ou de maintenir ceux existants afin d'assurer la bonne santé des outils informatiques internes. + +## Partager mes expériences 🧠 + +Partager des connaissances et des leçons est quelque chose de vraiment important pour moi. Lorsque j'ai commencé à apprendre à coder, j'ai rencontré de nombreux échecs et déceptions, mais ces obstacles m'ont enrichi d'une certaine manière. Donc, lorsque je fais quelque chose dont j'ai appris quelque chose, j'essaie de **le partager** sur Internet pour obtenir des retours. +Le blog ici est un exemple de cela, mais je publie également sur mon **Twitter** et j'ai également donné une petite conférence lors de Polycloud 2025 pour parler de Twitch et des outils que je développe. Ce fut une bonne expérience ! + +> Lorsque vous partagez, vous continuez à apprendre, alors partageons les connaissances partout ! + +## What's Next... 🚀 + +Depuis 2020, je fais partie du groupe communautaire **InGDoc**. Avec eux, je crée des projets innovants et créatifs autour des données et d'Internet, puisque l'Internet ne mourra probablement jamais, vous pouvez vous attendre à ce que je crée beaucoup de choses autour de cela dans les mois à venir. Il me reste encore 2 ans avant d'obtenir mon diplôme, c'est mon prochain grand objectif, alors rendez-vous en 2027 ! + +Je suis **Pierre-Louis Leclerc**, et je vous remercie d'avoir lu cela. + +## Créons des choses incroyables ! 🚀 \ No newline at end of file diff --git a/src/pages/fr/blog/index.astro b/src/pages/fr/blog/index.astro new file mode 100644 index 0000000..cc89bba --- /dev/null +++ b/src/pages/fr/blog/index.astro @@ -0,0 +1,22 @@ +--- +import Layout from "../../../layouts/Layout.astro"; +import Hero from "../../../components/blog/Hero.astro"; +import Tags from "../../../components/blog/Tags.astro"; +import ListPosts from "../../../components/blog/ListPosts.astro"; +const pageTitle = "Blog - Pierre-Louis Leclerc | Proxyfil"; +const description = "Tout ce que je veux partager sous forme de blog."; +const ogimage ={ + url: "/images/blogimage.webp", + alt: "Logo proxyfil.fr avec un fond doré et un effet de lumière. Texte: 'Lets learn tech !' et URL 'www.proxyfil.fr'." +}; +--- + + + + + +
+ +
+ +
diff --git a/src/pages/fr/blog/posts/create-deb-package.md b/src/pages/fr/blog/posts/create-deb-package.md new file mode 100644 index 0000000..f32d914 --- /dev/null +++ b/src/pages/fr/blog/posts/create-deb-package.md @@ -0,0 +1,210 @@ +--- +layout: /src/layouts/MarkdownPostLayout.astro +title: Comment créer un paquet .deb et mettre en place un miroir pour le distribuer +author: Pierre-Louis Leclerc | Proxyfil +description: "Tout programme peut être transformé en paquet .deb. C'est vraiment utile pour les petits scripts qu'on utilise souvent, essayons d'en créer un ensemble ! ⚙️" +image: + url: "/images/posts/create-deb-package.webp" + alt: "Illustration avec le logo Linux et un paquet." +pubDate: 2025-06-02 +tags: + [ + "Bash", "System" + + ] +languages: ["bash"] +--- + +Créer un paquet .deb peut s'avérer vraiment pratique quand on a des tâches répétitives à effectuer. C'est également un moyen simple de partager ses outils via un miroir et de diffuser ses paquets de gestion système sur internet. +Voyons ensemble comment créer un paquet et le partager au monde entier ! + +## 📁 Comment créer votre paquet ? + +### 🏗️ Architecture du paquet + +--- + +N'importe quoi peut devenir un paquet .deb, c'est la principale chose à retenir. +Avec ce travail, il existe des standards dans l'architecture de votre paquet, vous devriez suivre ces conventions : + +```bash +[package_name]/ +├── DEBIAN/ +│ ├── control +│ └── postinst +│ └── prerm +└── lib/[package_name]/ + └── [script_sources] +``` + +Ici, on peut remarquer plusieurs choses. Le dossier `DEBIAN` contient tous les hooks et informations générales de notre paquet, c'est un dossier essentiel pour le paquet. +L'autre dossier contient l'emplacement d'installation de notre paquet ainsi que nos fichiers sources. + +### 📝 Contenu du fichier control + +--- + +Le fichier control est le cœur du paquet. Voici un exemple ci-dessous. + +```bash +Package: mypackage +Version: 1.0 +Architecture: all +Maintainer: Example +Depends: python3, python3-tz +Description: Show the date +``` + +On peut voir ici les informations sur le paquet ainsi que les dépendances et la description. +Il est également possible d'ajouter des conflits avec des dépendances pour les supprimer du système. + +### 📝 Contenu des fichiers postinst et prerm + +--- + +Le fichier postinst est utilisé pour exécuter un script après le déploiement des fichiers du paquet, c'est utile pour créer des liens symboliques. + +```bash +#!/bin/bash +ln -s /usr/lib/[example]/[example.py] /usr/bin/[example] +chmod +x /usr/bin/[example] +``` + +Le fichier prerm est utilisé pour exécuter un script après la suppression des fichiers. + +```bash +#!/bin/bash +rm -f /usr/bin/[example] +``` + +Si vous le souhaitez, vous pouvez utiliser plusieurs hooks différents mais nous n'aborderons pas ce sujet ici. + +### 🆕 Créer notre paquet + +--- + +Pour créer un paquet .deb, nous allons utiliser `dpkg-deb` comme dans la commande suivante, vous devez l'exécuter au-dessus du dossier de votre paquet. + +```bash +dpkg-deb --build [example] +``` + +Cela va créer un paquet .deb de notre code. + +### ✒️ Comment signer notre paquet + +--- + +Même si ce n'est pas obligatoire, nous allons sécuriser notre paquet. Pour ce faire, nous allons créer une clé privée avec `gpg`. + +```bash +gpg --full-generate-key +``` + +Après avoir saisi quelques informations, vous obtiendrez votre clé privée ou du moins un identifiant : **Notez l'ID quelque part** +Ensuite, nous allons signer le paquet avec gpg pour obtenir un fichier .deb.sig. + +```bash +gpg --default-key [key_id] --detach-sign [example.deb] +``` + +Enfin, nous allons récupérer la clé publique qui authentifiera la signature du paquet. + +```bash +gpg --armor --export [key_id] > pubkey.asc +``` + +Félicitations, vous avez votre paquet signé ! 👏 + +--- + +## 🗄️ Comment créer un miroir et l'authentifier + +### 🪞 Comment créer un miroir simple + +--- + +Pour distribuer un paquet, un simple serveur apache suffit (ou NGINX pour ceux qui préfèrent) + +Ensuite, nous plaçons le fichier .deb dans un dossier et nous créons les fichiers obligatoires pour distribuer le paquet dans un miroir : + +```bash +cd /var/www/html/deb +dpkg-scanpackages . /dev/null | gzip -9c > Packages.gz +``` + +Après avoir fait cela, nous pouvons ajouter le miroir à nos sources sur notre système en modifiant le fichier +``` +/etc/apt/sources.list.d/[package].list +``` +et en y mettant + +```bash +deb [trusted=yes] http://localhost/deb ./ +``` + +Vous pouvez remplacer `http://localhost/deb` par le chemin réel de votre paquet dans le miroir. +Maintenant que c'est fait, nous pouvons mettre à jour nos sources et télécharger le paquet sur notre système : + +```bash +sudo apt update +sudo apt install [example.deb] +``` + +### 🔑 Comment authentifier la release de notre paquet ? + +--- + +Pour identifier notre dépôt et notre release, nous allons utiliser la clé privée générée précédemment pour signer notre fichier Release dans le miroir. +D'abord, nous créons un fichier Release non signé : + +```bash +apt-ftparchive release . > Release +``` + +Ensuite, nous signons le fichier avec `gpg` : + +```bash +gpg --default-key [key_id] -abs -o Release.gpg Release +``` + +Nous pouvons également signer un fichier InRelease qui est la méthode à privilégier car elle est à jour et plus récente. + +```bash +gpg --default-key [key_id] --clearsign -o InRelease Release +``` + +Félicitations, vous avez créé votre paquet ainsi que votre miroir et l'avez sécurisé ! 👏 + +### 🔍 Comment cela se passe-t-il en réalité + +Dans de nombreux cas, vous ne créerez que les fichiers `InRelease` et `Release` car c'est plus récent et cela coûte moins cher pour apt. +Vous pouvez également créer un fichier `Release.gpg` mais c'est l'ancienne méthode et cela nécessite qu'apt fasse 2 requêtes au serveur au lieu d'une. + +Signer le paquet n'est pas vraiment utilisé de nos jours, c'est plutôt utilisé pour signer le fichier Release du miroir. + +Par exemple, nous pouvons voir ci-dessous comment un miroir est souvent structuré : + +```bash +dists/ +└── stable/ + ├── Release + ├── InRelease + └── main/ + ├── binary-amd64/ + │ └── Packages + ├── binary-i386/ + │ └── Packages + └── source/ + └── Sources +``` + +Nous pouvons créer plusieurs releases pour différents programmes et architectures, c'est vraiment utile quand vous voulez distribuer beaucoup de paquets. +Comme nous pouvons le voir, nous n'avons qu'un seul fichier `Release` et `InRelease` pour tout le miroir, c'est parce que nous n'allons pas signer les paquets mais seulement le fichier release. +C'est la façon la plus courante de créer un miroir et de distribuer des paquets, c'est aussi la façon utilisée par les miroirs officiels Debian et Ubuntu. + +## 💚 Conclusion + +Créer un paquet peut être utile dans tellement de situations, c'est l'une des façons de le faire et de gérer votre système mais vous avez plein d'autres façons de le faire. + +J'espère que cet article vous a été utile, à bientôt... \ No newline at end of file diff --git a/src/pages/fr/blog/posts/index.astro b/src/pages/fr/blog/posts/index.astro new file mode 100644 index 0000000..1a0b784 --- /dev/null +++ b/src/pages/fr/blog/posts/index.astro @@ -0,0 +1,54 @@ +--- +import Layout from "../../../../layouts/Layout.astro"; +import Hero from "../../../../components/blog/Hero.astro"; +import Tags from "../../../../components/blog/Tags.astro"; +import ListPosts from "../../../../components/blog/ListPosts.astro"; +const pageTitle = "Blog - Pierre-Louis Leclerc | Proxyfil"; +const description = "Tout ce que je veux partager sous forme de blog."; +const ogimage ={ + url: "/images/blogimage.webp", + alt: "Logo proxyfil.fr avec un fond doré et un effet de lumière. Texte: 'Lets learn tech !' et URL 'www.proxyfil.fr'." +}; +const currentUrl = `${Astro.site}${Astro.url.pathname}`; +const tweetText = encodeURIComponent(`"${pageTitle}"`); +import Heading from "../../../../components/ui/Heading.astro"; +import Share from "../../../../components/ui/Share.astro"; + +import Languages from "../../../../components/blog/Languages.astro"; +--- + + +
+
+
+ +
+ +
+
+
+ + +
+
+ +
+
+ + +
+
+
+ +
+
+
+
+ +
diff --git a/src/pages/blog/posts/lettre.md b/src/pages/fr/blog/posts/lettre.md similarity index 100% rename from src/pages/blog/posts/lettre.md rename to src/pages/fr/blog/posts/lettre.md diff --git a/src/pages/fr/blog/posts/retex-sfh.md b/src/pages/fr/blog/posts/retex-sfh.md new file mode 100644 index 0000000..31a7984 --- /dev/null +++ b/src/pages/fr/blog/posts/retex-sfh.md @@ -0,0 +1,158 @@ +--- +layout: /src/layouts/MarkdownPostLayout.astro +title: Retour d'expérience d'incident avec Stream For Humanity +author: Pierre-Louis Leclerc | Proxyfil +description: "Tout ne se passe pas toujours comme prévu. En travaillant sur Stream For Humanity avec Tungstene, j'ai été ciblé par une attaque DDOS et j'ai appris à la dure comment gérer ce type d'attaque... Partageons les leçons que j'en ai tirées." +image: + url: "/images/posts/retex-sfh.webp" + alt: "Illustration de Stream For Humanity avec le titre de l'article de blog" +pubDate: 2025-01-20 +tags: + [ + "Retex", "System", "Twitch", "Network" + + ] +languages: ["vue", "kubernetes", "cloudflare"] +--- + +Parfois on a des plans mais l'univers en a d'autres pour nous... Je m'en suis rendu compte ce mois-ci car j'ai été attaqué sur mon système. Pour être honnête, c'était un peu de ma faute et même si ce n'était pas un gros problème, j'ai dû trouver comment le gérer. + +Plongeons dans le problème ! + +## ❓ Rapport d'incident : Attaque par déni de service distribué (DDoS) + +### 1. Résumé de l'incident + +Résumons le contexte de cette attaque : + +Le **17 janvier 2025**, une attaque par déni de service distribué (DDoS) a été détectée sur l'infrastructure fournie par l'école. L'attaque a commencé à 22h30 (UTC+1) et s'est poursuivie jusqu'à 23h08 (UTC+1), affectant les services déployés pour l'événement "Stream For Humanity", ainsi que les performances de mon serveur et du service SSH dessus. Tout le trafic acheminé via un proxy Cloudflare a été redirigé vers le réseau DO de l'Université de Montpellier, ciblant le serveur "Antlia". + +Cette attaque a utilisé des techniques avancées, notamment des **attaques au niveau de la couche application**, et a principalement ciblé les services d'API web déployés sur la lame serveur. Les mesures d'atténuation ont permis de restaurer les services essentiels en 20 minutes. + +--- + +### 2. Comment je l'ai détectée et comment j'ai répondu initialement + +L'attaque a été détectée via les outils de surveillance du serveur et les alertes Cloudflare. Les anomalies initiales comprenaient une augmentation de l'utilisation du CPU et une hausse significative de la latence réseau. +C'était inhabituel pour un tel service lors d'un tel événement et même si j'avais déployé quelque chose d'un peu nouveau sur mon infrastructure, j'ai rapidement réalisé que quelque chose n'allait pas... + +Les actions suivantes ont été prises rapidement : + +- **Blocage** des adresses IP malveillantes identifiées +- Activation des **règles d'urgence** sur le pare-feu d'applications web (WAF) +- Déploiement d'**applications supplémentaires** pour absorber le trafic + +--- + +### 3. Analyse de l'attaque + +Par la suite, j'ai pris le temps d'analyser l'attaque, d'identifier le problème principal et comment il a été exploité pour causer de tels dégâts. + +L'analyse a révélé le vecteur suivant : + +- **Attaque au niveau de la couche application :** Requêtes HTTP GET massives ciblant "sfh.proxyfil.fr/api/pools/streamers" (et par extension le domaine antlia.dopolytech.fr/api/pools/streamers) + +On peut noter quelques caractéristiques sur certains aspects techniques de l'attaque : + +- **Volume total :** ~200 000 requêtes par minute, environ ~3 000 requêtes par seconde (RPS), avec un pic à 4,73 millions de requêtes par minute atténuées par le WAF de Cloudflare à 22h52 +- **Sources de l'attaque :** Un grand nombre d'adresses IP provenant de nombreux pays, principalement l'Indonésie (7,5 millions), les USA (3,74 millions), l'Inde (1,6 million), le Brésil (1,36 million), la Turquie (1,3 million) et la Chine (volume inconnu) +- **Outils utilisés par les attaquants :** Probablement des botnets + +Une analyse géographique a montré une concentration d'attaquants en Asie et dans les Amériques. + +**Volume total de l'attaque :** + +- **8 Go** de données ont été servis par le serveur pendant l'attaque, **161,92 Go** ont été servis par le cache de Cloudflare +- **5 millions de requêtes** ont été servies par le serveur, **25 millions** ont été mises en cache ou bloquées par Cloudflare +- **5 pays** représentaient plus de la moitié des requêtes traitées par le serveur +- Sur les **30 millions de requêtes enregistrées** pendant l'attaque, environ **27 millions** se sont terminées sans réponse en raison de la surcharge du serveur ou de la réponse du WAF +- L'utilisation du CPU sur la lame serveur a atteint **90 %** pendant l'attaque, l'utilisation de la RAM a augmenté de **6 Go** + +--- + +### 4. Impacts sur les services + +**Services impactés :** + +- Services sur le serveur Antlia : Performances fortement dégradées pendant plusieurs minutes +- Lame serveur Antlia : Performances fortement dégradées pendant plusieurs minutes +- Services API et Frontend : Performances fortement dégradées pendant quelques minutes + +--- + +### 5. Actions correctives immédiates + +Les actions suivantes ont été prises pour restaurer les opérations : + +- Déploiement d'un WAF via Cloudflare pour bloquer les requêtes illégitimes +- Augmentation de la disponibilité des services pour absorber le trafic +- Surveillance renforcée des systèmes pendant les heures suivantes + +--- + +### 6. Conclusion + +Cet incident met en évidence la nécessité d'une vigilance accrue face aux menaces DDoS, qui deviennent de plus en plus sophistiquées. Bien que les mesures existantes aient contribué à limiter l'impact, l'amélioration continue des outils et des processus de sécurité est essentielle pour garantir la disponibilité des services. + +Les leçons tirées de cet incident éclaireront les futures stratégies de sécurité, garantissant une réponse rapide et efficace aux futures attaques, et servant d'avertissement aux autres étudiants qui prévoient de déployer de tels services sur l'infrastructure DO. + +--- + +### 7. Chronologie de l'attaque + +**22h30 :** Début de l'attaque DDoS +**22h36 :** Déconnexion du VPN Polytech +**22h37 :** Enquête auprès des pairs ; le site web est inaccessible +**22h39 :** Alerte d'utilisation du CPU pour le serveur Antlia +**22h40 :** VPN restauré +**22h41 :** Déploiement de 2 nouveaux services pour absorber le trafic +**22h43 :** WAF déployé pour bloquer les 3 principaux pays attaquants +**22h46 :** Blocage de 4 pays attaquants supplémentaires +**22h49 :** Première offensive terminée +**22h51 :** Début de la deuxième offensive, entièrement atténuée par le WAF de Cloudflare +**22h52 :** Pic d'attaque, 4,73 millions de requêtes atténuées. Infrastructure DO/Polytech/UM épargnée +**23h01 :** Retour au service normal, confirmé par les métriques du serveur Antlia +**23h08 :** Fin de l'attaque DDoS + +--- + +### 8. Annexes + +Pays d'origine des attaques (Top 5 des attaquants) : + +![country_details.webp](/images/posts/retex-sfh/country_details.webp) + +Volume de l'attaque : + +![total_traffic.webp](/images/posts/retex-sfh/total_traffic.webp) + +Statistiques de l'attaque : + +![sources_traffic.webp](/images/posts/retex-sfh/sources_traffic.webp) + +Cartographie de l'attaque : + +![map.webp](/images/posts/retex-sfh/map.webp) + +Volume de l'attaque : + +![cached_requests.webp](/images/posts/retex-sfh/cached_requests.webp) + +Volume de données de l'attaque : + +![cached_bandwidth.webp](/images/posts/retex-sfh/cached_bandwidth.webp) + +Utilisation du CPU pendant l'attaque : + +![cpu_usage.webp](/images/posts/retex-sfh/cpu_usage.webp) + +Utilisation de la RAM pendant l'attaque : + +![ram_usage.webp](/images/posts/retex-sfh/ram_usage.webp) + + +## 💚 Conclusion + +Cette expérience a été vraiment utile pour moi car j'ai beaucoup appris sur la sécurisation des services. C'était la première fois que j'étais vraiment attaqué et je suis sûr que la prochaine fois, je m'assurerai d'avoir des protections pour me défendre contre de telles méthodes d'attaque sournoises. + +J'espère que cet article vous a été utile, à bientôt... \ No newline at end of file diff --git a/src/pages/fr/blog/posts/retex-zevent2025.md b/src/pages/fr/blog/posts/retex-zevent2025.md new file mode 100644 index 0000000..b445fdc --- /dev/null +++ b/src/pages/fr/blog/posts/retex-zevent2025.md @@ -0,0 +1,123 @@ +--- +layout: /src/layouts/MarkdownPostLayout.astro +title: Anti-DDOS, Rust et apprendre de ses erreurs +author: Pierre-Louis Leclerc | Proxyfil +description: "Depuis maintenant 4 ans, je collecte des statistiques autour de l'événement caritatif 'ZEvent'. Et même si j'ai l'habitude, il y a toujours de nombreux problèmes auxquels je dois faire face quand on travaille à cette échelle ! Parlons un peu des problèmes autour des événements caritatifs et des mauvais choix techniques." +image: + url: "/images/posts/retex-zevent2025.webp" + alt: "Illustration du ZEvent2025 avec le titre de l'article de blog" +pubDate: 2025-09-17 +tags: + [ + "Retex", "System", "Twitch", "Rust" + + ] +languages: ["vue", "kubernetes", "cloudflare"] +--- + +Depuis 2020, je fais du travail communautaire autour du ZEvent. Cet événement est le plus grand événement caritatif français hébergé sur Twitch, avec plus de 16 000 000€ collectés en 2025, c'est la plus importante collecte de fonds avec des streamers chaque année. + +Chaque année, il y a plus de personnes qui essaient de participer, plus de POV à suivre, plus d'objectifs de dons à voir et un spectateur moyen peut rapidement être submergé par toutes ces informations. +Depuis 5 ans maintenant, je travaille avec un groupe de personnes pour donner aux spectateurs des outils et des métriques pour mieux suivre et comprendre cet événement en temps réel ou après l'événement. + +Parlons un peu de mon travail et de ce qui rend cette année spéciale ! + +## 📱 Travail habituel + + +### 📜 Liste des objectifs de dons + +Depuis 2020, nous travaillons ensemble avec [l'équipe](https://gdoc.fr/team) pour donner à tout le monde un endroit avec tous les objectifs de dons. +Les objectifs de dons sont des objectifs avec des montants en euros, chaque streamer a les siens pour les dons qu'il collecte. + +En 2021, il y avait 49 streamers ensemble pendant ~54 heures, collectant tous ensemble plus de 10 000 000€ pour la première fois. +Cette année, environ 800 objectifs de dons ont été créés et 682 atteints, bien plus qu'un spectateur moyen ne peut suivre. + + + + +### 📊 Statistiques + +Depuis 2021, nous créons des infographies et des visuels avec de nombreuses données autour du ZEvent et des métriques Twitch. + +Les données peuvent concerner le temps de stream, le temps visionné, le nombre d'emotes ou de messages envoyés. +Tout est collecté via l'API officielle de Twitch dans postgresql, derrière tout ça nous avons des scripts NodeJS et Python pour faire tout le travail. +Chaque visuel est planifié avant l'événement sur Figma (c'est merveilleux). + +Voici un exemple de ce que nous avons conçu pour 2025 : + + + +Au fil des années, nous avons augmenté nos capacités. Aujourd'hui, nous pouvons gérer jusqu'à 350 chaînes en même temps avec génération de métriques en temps réel. +L'événement se termine le lundi à 1h du matin chaque année, environ 5 heures plus tard, nous avons tous nos visuels prêts. + +Depuis 2 ans maintenant, nous générons également des infographies personnalisées pour chaque streamer, cette année ce sont environ ~350 visuels qui ont été générés automatiquement. +Cette année, tout est disponible [ici](https://stats.gdoc.fr/) + +### 🖼️ Place Atlas + +Depuis 2022, l'organisation du ZEvent propose 2 jeux disponibles pour la durée de l'événement : ZEventPlaysPokémon et ZEventPlace. + +Le premier est lié aux statistiques des chaînes mais le second est une pixel war pour la durée du week-end : 1€ = 10 pixels à placer. +C'est vraiment amusant et cela crée une nouvelle façon pour les gens de donner pour une autre raison que la charité uniquement. + +Avec cela, nous avons déployé un outil qui collecte des images du canvas toutes les X minutes et affiche les descriptions que la communauté soumet. +Ce workflow n'est pas parfait et nous avons changé des choses en cours de route mais il fonctionne presque parfaitement depuis 2022. + +Vous pouvez voir l'atlas [ici](https://atlas.gdoc.fr/) + +## ❓ Qu'est-ce qui a changé cette année ? + +### 🤖 Amélioration des capacités et des technologies + +Parfois, NodeJS et Python ne suffisent pas. +Parce que je n'ai pas vraiment mis à niveau mes scripts depuis 3 ans maintenant, une grande partie de ma stack concerne NodeJS et Python avec de mauvaises performances et un goût de scripts lancés sur des screens sans orchestration ni basculement. + +Cela a fonctionné pendant 3 ans, maintenant il fallait changer. +Avec cet objectif, j'ai commencé à conteneuriser une partie de ma stack pour ce ZEvent et j'ai commencé à refondre mes outils de collecte de statistiques avec rust et une conception master/slave. + +À l'époque, 1 script gérait chaque chaîne, maintenant nous avons un nœud master qui stocke toutes les informations de chaîne et d'événement. Les slaves se connectent au master pour collecter les données et les envoient via RabbitMQ pour ajouter une couche de buffer et de load-balancing. + +À la fin, les consommateurs récupèrent les données de RabbitMQ et stockent les données dans MongoDB. + +**Pourquoi changer cela ?** + +Mon ancien système avait beaucoup de problèmes : +- Un seul script pour tout +- Aucun moyen d'équilibrer le flux de requêtes +- Aucune résilience +- Une seule DB pour tout + +Maintenant, nous avons de petits services avec chacun son rôle. +Si un service plante, il est remplacé presque instantanément par un autre, ce qui le rend résilient. + +Et si un problème survient avec MongoDB ou les consommateurs, ce n'est pas grave : le RabbitMQ mettra les messages en buffer et en attente. +De grands changements concernant la DB ont amélioré la façon dont les objets sont stockés mais aussi l'adaptabilité de la DB et les performances en utilisant de bons index. + +### 📦 Passer du bare metal à K8S + +Les anciens services étaient des scripts, maintenant j'ai des conteneurs avec registre et déploiement helm spécifiquement pour cela. +Cela permet un déploiement rapide et des changements faciles de nœud. + +De plus, pas de soucis en cas de crashes maintenant : tout redémarre tout seul. + +Pour le sécuriser cette année, 3 serveurs ont été déployés pour gérer les applications et les sauvegardes afin de conserver toutes les données sans défaillance. + +### 👀 Est-ce que ça a fonctionné ? + +Oui, ça a parfaitement fonctionné ! Et même si ce système n'était pas le principal mais plutôt une sauvegarde cette année, il est en bonne voie pour devenir le système principal pour 2026. +Tungstene fera bientôt partie des projets **Chronobreak** mais nous en parlerons un peu plus tard. + +## 📅 Prochains objectifs + +Ça s'est bien passé cette année mais beaucoup de choses pourraient être faites concernant mon travail. + +Pour l'année prochaine, je prévois de mettre à niveau les projets de collecte de statistiques avec de nouvelles façons d'afficher les données pour chaque spectateur. +J'aimerais uniformiser notre infrastructure et notre site web avec Maniarr pour connecter ensemble tous les sous-éléments de nos outils. + +Les prochains plans sont de développer une manière unique de gérer les autorisations, continuer à mettre à niveau mes scripts en conteneurs généraux et être plus un Ops qu'un Dev. + +J'ai bon espoir que l'année prochaine tout sera plus propre. +Des statistiques à l'atlas, j'essaierai de vous tenir au courant et de travailler dessus régulièrement. + +À bientôt 🫡 \ No newline at end of file diff --git a/src/pages/fr/blog/tags/[tag].astro b/src/pages/fr/blog/tags/[tag].astro new file mode 100644 index 0000000..c61b7d4 --- /dev/null +++ b/src/pages/fr/blog/tags/[tag].astro @@ -0,0 +1,41 @@ +--- +import Layout from '../../../../layouts/Layout.astro'; +import BlogPost from '../../../../components/blog/BlogPost.astro'; +import Heading from '../../../../components/ui/Heading.astro'; + +export async function getStaticPaths() { + const allPosts: any[] = await Astro.glob("../posts/*.md"); + + const uniqueTags: string[] = [ + ...new Set(allPosts.map((post: any) => post.frontmatter.tags).flat()), + ]; + + return uniqueTags.map((tag) => { + const filteredPosts = allPosts.filter((post) => + post.frontmatter.tags.includes(tag), + ); + return { + params: { tag }, + props: { posts: filteredPosts }, + }; + }); +} + +const { tag } = Astro.params; +const { posts } = Astro.props; +--- + + +
+
+ + { + posts.map((post) => ( + + )) + } +
+
+ +
+ diff --git a/src/pages/fr/blog/tags/index.astro b/src/pages/fr/blog/tags/index.astro new file mode 100644 index 0000000..29789f0 --- /dev/null +++ b/src/pages/fr/blog/tags/index.astro @@ -0,0 +1,19 @@ +--- +import Layout from "../../../../layouts/Layout.astro"; +import Tags from "../../../../components/blog/Tags.astro"; +import Heading from "../../../../components/ui/Heading.astro"; + +const pageTitle = "Tags"; + + + +--- + + +
+
+ + +
+
+
diff --git a/src/pages/fr/blog/techs/[category].astro b/src/pages/fr/blog/techs/[category].astro new file mode 100644 index 0000000..8e0d984 --- /dev/null +++ b/src/pages/fr/blog/techs/[category].astro @@ -0,0 +1,55 @@ +--- +import Layout from "../../../../layouts/Layout.astro"; +import BlogPost from "../../../../components/blog/BlogPost.astro"; +import Heading from "../../../../components/ui/Heading.astro"; +import type { MarkdownInstance } from 'astro'; + +interface Frontmatter { + languages: string[]; + title: string; + pubDate: string; + tags: string[]; + image: string; +} +export async function getStaticPaths(): Promise[]}}>>{ + const allPosts: MarkdownInstance[] = await Astro.glob("../posts/*.md"); + + const uniqueTags: string[] = [ + ...new Set(allPosts.map((post: MarkdownInstance) => post.frontmatter.languages).flat()), + ]; + + return uniqueTags.map((category) => { + const filteredPosts = allPosts.filter((post) => + post.frontmatter.languages.includes(category) + ); + return { + params: { category }, + props: { posts: filteredPosts }, + }; + }); +} + +const { category } = Astro.params; +const { posts } = Astro.props; +--- + + +
+
+ + + { + posts.map((post) => ( + + )) + } +
+
+
diff --git a/src/pages/fr/blog/techs/index.astro b/src/pages/fr/blog/techs/index.astro new file mode 100644 index 0000000..46adad9 --- /dev/null +++ b/src/pages/fr/blog/techs/index.astro @@ -0,0 +1,20 @@ +--- +import Layout from "../../../../layouts/Layout.astro"; +import Languages from "../../../../components/blog/Languages.astro"; +import Heading from "../../../../components/ui/Heading.astro"; + +const pageTitle = "Technologies"; // Fixed: Title was "Languages" but heading shows "Technologies" +const allPosts = await Astro.glob("../posts/*.md"); +const languages = [ + ...new Set(allPosts.map((post) => post.frontmatter.languages).flat()), +]; +--- + + +
+
+ + +
+
+
diff --git a/src/pages/fr/index.astro b/src/pages/fr/index.astro new file mode 100644 index 0000000..46c66dc --- /dev/null +++ b/src/pages/fr/index.astro @@ -0,0 +1,66 @@ +--- +import Layout from "../../layouts/Layout.astro"; +import Experience from "../../components/portfolio/Experience.astro"; +import HeroIndex from "../../components/portfolio/HeroIndex.astro"; +import Contact from "../../components/portfolio/Contact.astro"; +import ListProjects from "../../components/portfolio/ListProjects.astro"; +import { Icon } from "astro-icon/components"; +const pageTitle = "Portfolio - Pierre-Louis Leclerc | Proxyfil"; +const description = "Everything you need to know about my portfolio as a DevOps engineer student. Discover my experience, projects, and skills in development and technology."; +const ogimage = { + url: "/images/imagedefault.webp", + alt: "Capture d'écran d'un portfolio avec un design moderne et sombre. Featuring Pierre-Louis Leclerc, un étudiant en DevOps avec 3 ans d'expérience, mettant en avant sa passion pour le développement et la technologie. Comprend des sections de contact, des projets et une pile technologique avec des technologies telles que HTML5, JavaScript, TypeScript, Angular, Node.js, CSS, Tailwind, et plus encore.", +}; +import Heading from "../../components/ui/Heading.astro"; +--- + + + + +
+
+
+ + +
+ + +
+
+ +
+
+ +
+ + +
+

+ J'aime transformer des idées en projets réels. +
Ici, je vous présente certains des projets sur lesquels j'ai travaillé, en appliquant différentes technologies, un peu de design et beaucoup de créativité. + Découvrez-les ! +

+
+
+
+ + + +
diff --git a/src/pages/fr/portfolio/projects/ingdoc.md b/src/pages/fr/portfolio/projects/ingdoc.md new file mode 100644 index 0000000..8dff061 --- /dev/null +++ b/src/pages/fr/portfolio/projects/ingdoc.md @@ -0,0 +1,43 @@ +--- +layout: /src/layouts/ProjectLayout.astro +title: 'InGDoc' +pubDate: 2025-04-05 +description: "Création d'outils pour aider à la visibilité des événements et réaliser des visuels percutants." +languages: ["vue", "node", "python","figma"] +image: + url: "/images/projects/ingdoc.webp" + alt: "Miniature d'InGDoc." +--- + +**InGDoc** est un groupe communautaire créé dans l'idée de fournir à certains grands événements Twitch des outils pour les rendre facilement regardables et fournir des statistiques à leur sujet. + +## 📖 Historique + +Créée en 2020, l'équipe a été fondée à l'origine pour suivre le ZEvent2020. Notre premier outil était un Google Document qui suivait la progression des objectifs de dons et les montants collectés. +En **2022**, nous commençons à collecter plus de données sur les streams et créons des infographies à la fin des différents événements que nous suivons. + +Nous avons travaillé avec certaines organisations pour créer des outils personnalisés et nous continuons à donner vie aux idées de la communauté. +Notre site web est une collection de tout le travail que nous avons accompli jusqu'à présent, comme la création d'atlas pour les **événements type r/place** ou des Google Sheets entiers pour suivre les **Progressions WoW**. +Vous pouvez également voir l'effort de documentation fait par la communauté pour suivre **RPZ**, un événement de roleplay sur GTA V qui a réuni plus de 100 streamers venus jouer et créer des histoires pendant 2 semaines d'affilée. + +## 📢 Événements suivis + +- ZEvent2020 +- Progress WoW Shadowlands +- RPZ +- ZRT Trackmania Cup 2021 +- ZEvent2021 +- Progress WoW Dragonflight +- ZEvent2022 +- ZRT Trackmania Cup 2022 +- Speedons 2024 +- WeekAndArt 2024 +- ZEvent2024 +- Progress WoW The War Within +- Stream For Humanity +- WeekAndArt 2025 + +## 🌐 Liens utiles + +- [Site web](https://gdoc.fr/) +- [Twitter](https://x.com/Les_InGdoc) diff --git a/src/pages/fr/portfolio/projects/pollpy.md b/src/pages/fr/portfolio/projects/pollpy.md new file mode 100644 index 0000000..4523870 --- /dev/null +++ b/src/pages/fr/portfolio/projects/pollpy.md @@ -0,0 +1,31 @@ +--- +layout: /src/layouts/ProjectLayout.astro +title: 'PollPy' +pubDate: 2024-08-21 +description: 'Bot Discord pour créer des prédictions et construire une communauté autour.' +languages: ["nodejs", "git", "bash"] +image: + url: "/images/projects/pollpy.webp" + alt: "Miniature du projet PollPy." +--- + +**PollPy** était un bot Discord pour implémenter des prédictions et des sondages de manière simple pour les utilisateurs finaux. Il était conçu pour être amusant avec des points virtuels et différents types de prédictions. + +Construit en un été, il était vraiment basique mais a reçu quelques mises à jour au fil des années pour ajouter des fonctionnalités et est passé de quelques serveurs à plus de 100 l'utilisant avec plus de 10 000 utilisateurs. + +## 🧩 Fonctionnalités + +- Créer des prédictions et permettre aux utilisateurs de participer +- Les clôturer et redistribuer les points + +## 💡 Technologies utilisées + +- NodeJS +- Supabase + +## 🎯 Objectif + +Ce bot répondait à un besoin de bot de prédiction à une période où Discord n'avait pas encore créé de fonctionnalité pour cela. + + +🚀 *Développé par Proxyfil.* diff --git a/src/pages/fr/portfolio/projects/tungstene_enriched.md b/src/pages/fr/portfolio/projects/tungstene_enriched.md new file mode 100644 index 0000000..4ceea63 --- /dev/null +++ b/src/pages/fr/portfolio/projects/tungstene_enriched.md @@ -0,0 +1,42 @@ +--- +layout: /src/layouts/ProjectLayout.astro +title: 'Tungstene Enriched' +pubDate: 2025-02-09 +description: 'Tungstene Enriched est un outil pour collecter, transformer et restituer des données de Twitch.' +languages: ["nodejs", "cloudflare", "postgresql", "python", "vue"] +image: + url: "/images/projects/tungstene.webp" + alt: "Miniature de Tungstene." +--- + +**Tungstene Enriched** est un outil pour collecter, transformer et restituer des données de Twitch. L'outil est principalement axé sur le chat IRC mais collecte également des données depuis l'API Twitch. +Il utilise **NodeJS** pour se connecter à toutes les sources de données et stocke les informations dans une base de données **Postgresql**. + +Aujourd'hui âgé de 4 ans avec 1 refonte, le projet doit être remis aux nouveaux standards dans les prochains mois. + +## 🧩 Fonctionnalités + +- Collecte de messages depuis l'IRC Twitch +- Collecte de données depuis l'API Twitch +- Stockage des données dans Postgresql +- Transformation des données pour créer des résumés +- Récupération des données et création d'infographies avec celles-ci + +## 💡 Technologies utilisées + +- NodeJS +- Postgresql +- VueJS +- Python + +## 🌐 Démo + +👉 [Voir à quoi cela sert](https://x.com/Les_InGdoc/status/1914590807941689346) + +## 🎯 Objectif + +Le but principal de ce projet était d'apprendre à interagir avec des API et de découvrir à quel point certains événements sont importants sur Twitch. +Ces données sont utilisées pour créer des infographies et aider la communauté à interagir ensemble. + + +🚀 *Développé par Proxyfil.* diff --git a/src/pages/fr/robots.txt.ts b/src/pages/fr/robots.txt.ts new file mode 100644 index 0000000..d985f35 --- /dev/null +++ b/src/pages/fr/robots.txt.ts @@ -0,0 +1,15 @@ +import type { APIRoute } from 'astro'; + +const getRobotsTxt = (sitemapURL: URL) => ` +User-agent: * +Allow: / + +Sitemap: ${sitemapURL.href} + + +`; + +export const GET: APIRoute = ({ site }) => { + const sitemapURL = new URL('sitemap-index.xml', site); + return new Response(getRobotsTxt(sitemapURL)); +}; \ No newline at end of file diff --git a/src/pages/fr/rss.xml.js b/src/pages/fr/rss.xml.js new file mode 100644 index 0000000..1cc4230 --- /dev/null +++ b/src/pages/fr/rss.xml.js @@ -0,0 +1,11 @@ +import rss, { pagesGlobToRssItems } from '@astrojs/rss'; + +export async function GET(context) { + return rss({ + title: 'DevOps and Technology Blog | Pierre-Louis Leclerc | Proxyfil', + description: 'Welcome to my blog, where I share my passion for computer science, DevOps, and the latest technology trends.', + site: context.site, + items: await pagesGlobToRssItems(import.meta.glob('./**/*.md')), + customData: `en`, + }); +} \ No newline at end of file diff --git a/src/pages/index.astro b/src/pages/index.astro index 4ad87f6..2df6db6 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,66 +1,5 @@ --- -import Layout from "../layouts/Layout.astro"; -import Experience from "../components/portfolio/Experience.astro"; -import HeroIndex from "../components/portfolio/HeroIndex.astro"; -import Contact from "../components/portfolio/Contact.astro"; -import ListProjects from "../components/portfolio/ListProjects.astro"; -import { Icon } from "astro-icon/components"; -const pageTitle = "Portfolio - Pierre-Louis Leclerc | Proxyfil"; -const description = "Everything you need to know about my portfolio as a DevOps engineer student. Discover my experience, projects, and skills in development and technology."; -const ogimage = { - url: "/images/imagedefault.webp", - alt: "Screenshot of a web portfolio with a modern and dark design. Featuring Pierre-Louis Leclerc, a DevOps engineer student with 3 years of experience, highlighting his passion for development and technology. Includes contact sections, projects, and a technology stack with technologies like HTML5, JavaScript, TypeScript, Angular, Node.js, CSS, Tailwind, and more.", -}; -import Heading from "../components/ui/Heading.astro"; ---- - - - - -
-
-
- - -
- - -
-
- -
-
- -
- - -
-

- I love turning ideas into real projects. -
Here I show you some of the developments I've worked on, applying technology, design, and lots of creativity. - Check them out! -

-
-
-
+--- - -
+ \ No newline at end of file From 88304fab9a7a0c23f4809977edc8de6b50754e13 Mon Sep 17 00:00:00 2001 From: Proxyfil Date: Sun, 26 Oct 2025 11:23:46 +0100 Subject: [PATCH 2/3] fix(tags): Fixed tags importation --- src/pages/en/blog/tags/index.astro | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/en/blog/tags/index.astro b/src/pages/en/blog/tags/index.astro index 93b0243..29789f0 100644 --- a/src/pages/en/blog/tags/index.astro +++ b/src/pages/en/blog/tags/index.astro @@ -1,7 +1,7 @@ --- -import Layout from "../../../layouts/Layout.astro"; -import Tags from "../../../components/blog/Tags.astro"; -import Heading from "../../../components/ui/Heading.astro"; +import Layout from "../../../../layouts/Layout.astro"; +import Tags from "../../../../components/blog/Tags.astro"; +import Heading from "../../../../components/ui/Heading.astro"; const pageTitle = "Tags"; From 53a6c29ad86d5f7fb9e10cc8f4bd4795eae988a3 Mon Sep 17 00:00:00 2001 From: Proxyfil Date: Wed, 5 Nov 2025 10:05:52 +0100 Subject: [PATCH 3/3] chore: Created blog post minecraft on kubernetes --- public/images/posts/minecraft-kubernetes.webp | Bin 0 -> 56742 bytes .../en/blog/posts/minecraft-kubernetes.md | 93 ++++++++++++++++++ .../fr/blog/posts/minecraft-kubernetes.md | 93 ++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 public/images/posts/minecraft-kubernetes.webp create mode 100644 src/pages/en/blog/posts/minecraft-kubernetes.md create mode 100644 src/pages/fr/blog/posts/minecraft-kubernetes.md diff --git a/public/images/posts/minecraft-kubernetes.webp b/public/images/posts/minecraft-kubernetes.webp new file mode 100644 index 0000000000000000000000000000000000000000..52e91704f1d7ae3e9615d1a0817f199af98f08af GIT binary patch literal 56742 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
F_!@OTYT;!|Hi3o662qfya=Gj%6hQ&l+N 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 literal 0 HcmV?d00001 diff --git a/src/pages/en/blog/posts/minecraft-kubernetes.md b/src/pages/en/blog/posts/minecraft-kubernetes.md new file mode 100644 index 0000000..f766ab8 --- /dev/null +++ b/src/pages/en/blog/posts/minecraft-kubernetes.md @@ -0,0 +1,93 @@ +--- +layout: /src/layouts/MarkdownPostLayout.astro +title: Creating a Minecraft Server with Kubernetes +author: Pierre-Louis Leclerc | Proxyfil +description: "Minecraft is a popular game that can be hosted in different ways. Let's discover how to deploy a Minecraft server using Kubernetes! 🎮☁️" +image: + url: "/images/posts/minecraft-kubernetes.webp" + alt: "Article illustration" +pubDate: 2025-11-05 +tags: + [ + "Retex", "System", "Kubernetes" + ] +languages: ["kubernetes", "bash", "docker"] +--- + +For several years now, Minecraft has been my favorite game. I can't count the hours I've spent on it, whether solo or multiplayer with friends. + +A long time ago, I hosted Minecraft servers on classic VPS, but with the evolution of technology, I decided to explore Kubernetes to deploy my Minecraft server. +So I went from a classic VPS infrastructure with daemons to migrate to Kubernetes, taking advantage of this change to perform a major Minecraft version upgrade. + +## 🚛 Moving from Bare Metal to Kubernetes + +### ❓ Why Kubernetes? + +Kubernetes is a container orchestration platform that offers many advantages for hosting applications, including game servers like Minecraft. +We can note some key advantages such as scalability, resilience and ease of resource management. + +The main reason was curiosity to explore Kubernetes and see how it could be used to host a Minecraft server. Resources already existed to deploy Minecraft servers on Docker, so the next logical step was to move to Kubernetes. + +Moreover, I already have a trajectory to move towards a cloud provider-independent infrastructure, already having a Kubernetes node at home, it was the perfect opportunity to perform this migration. + + +### 📜 Infrastructure Definition + +To host these Minecraft servers (or rather an entire network in this case) I used a machine with an Intel(R) Xeon(R) CPU E3-1270 v6 @ 3.80GHz, 32GB of RAM and 256GB of SSD storage. + +To deploy a Minecraft server network, the historical solution was BungeeCord. However, for a few years now, Velocity (with its predecessor Waterfall) has been the favored solution. +Velocity acts as a proxy between players and Minecraft servers, allowing efficient connection management and load balancing between multiple servers or seamless movement from instance to instance within the same network. + +Behind Velocity, I deployed several Minecraft servers with different roles: +- A Lobby server: The main entry point for players, where they can prepare before joining other servers. +- A Survival server: A server dedicated to survival mode + +For now nothing more but the configuration remains flexible to add other servers in the future. + +### 🖥️ Deployment + +For deployment, all resources used are available on [my GitHub repo](https://github.com/Proxyfil/Deployments/tree/main/minecraft). + +Each server is deployed as a Kubernetes StatefulSet and exposes a particular port for communication with Minecraft clients. + +All storage is done directly on the host node's filesystem via PersistentVolumes linked to PersistentVolumeClaims. It's true that this approach is not ideal for horizontal scaling but for personal use it's sufficient. +A future improvement could be to use network storage like NFS or Ceph to allow better flexibility. + +Each server uses a custom Docker image based on an existing Minecraft image, with specific configurations for each server type. (Thanks to [itzg](https://github.com/itzg/docker-minecraft-server) who produces considerable work with other contributors!) + +### 🔐 Connection Issue + +Minecraft by default uses port 25565 for incoming connections. However Kubernetes does not allow directly exposing port 25565 via a LoadBalancer or an Ingress. + +To work around this problem I deployed an Nginx container as a LoadBalancer that redirects incoming traffic on port 25565 to the port exposed by the Kubernetes service. + +## ⬆️ Improvements + +### 🚀 Migration to Version 1.21.10 + +In addition to the migration to Kubernetes, I took the opportunity to perform a major Minecraft version upgrade, going from version 1.20.4 to version 1.21.10. + +This latest version being very recent (at the time of writing), I had to make sure that all the plugins used were compatible with this version. + +Fortunately, most popular plugins had already been updated to be compatible with version 1.21.x, but I had to make some minor adjustments in the configuration of some plugins to ensure full compatibility. + +Despite this, the deployment remains quite unstable from one minor version to another, I imagine this will improve when updates move to full version and no longer beta or alpha. + +### 📊 Monitoring + +To monitor the performance and health of my Minecraft server, I integrated monitoring tools into my Kubernetes cluster. I use Prometheus to collect metrics and Grafana to visualize this data. + +This allows me to track resource usage, connection latency and other key performance indicators, which is crucial to ensure a smooth gaming experience for players. +All deployment resources are available on [my GitHub repo](https://github.com/Proxyfil/Deployments/tree/main/monitoring/prometheus) with the necessary configurations to deploy Prometheus in Kubernetes. + +I also added a Grafana dashboard specific to Minecraft, which displays information such as the number of connected players, memory and CPU usage, and other relevant metrics. +With this I also have a Node Exporter to monitor the state of the host node. + +## 📅 Next Objectives + +Now that things are stable, I have some ideas to further improve my Minecraft deployment on Kubernetes. +We could move to provisioned storage, improve monitoring with alerts or add metrics with plugins. + +But for now I'm quite satisfied with the current configuration and I'm fully enjoying my Minecraft server hosted on Kubernetes! + +See you soon 🫡 diff --git a/src/pages/fr/blog/posts/minecraft-kubernetes.md b/src/pages/fr/blog/posts/minecraft-kubernetes.md new file mode 100644 index 0000000..6180fbf --- /dev/null +++ b/src/pages/fr/blog/posts/minecraft-kubernetes.md @@ -0,0 +1,93 @@ +--- +layout: /src/layouts/MarkdownPostLayout.astro +title: Créer un serveur Minecraft avec Kubernetes +author: Pierre-Louis Leclerc | Proxyfil +description: "Minecraft est un jeu populaire qui peut être hébergé de différentes manières. Découvrons comment déployer un serveur Minecraft à l'aide de Kubernetes ! 🎮☁️" +image: + url: "/images/posts/minecraft-kubernetes.webp" + alt: "Illustration de l'article" +pubDate: 2025-11-05 +tags: + [ + "Retex", "System", "Kubernetes" + ] +languages: ["kubernetes", "bash", "docker"] +--- + +Depuis quelques années maintenant Minecraft est mon jeu de coeur. Je ne compte plus les heures passées dessus, que ce soit en solo ou en multijoueur avec des amis. + +Il y a bien longtemps, j'ai hébergé des serveurs Minecraft sur des VPS classiques, mais avec l'évolution de la technologie, j'ai décidé d'explorer Kubernetes pour déployer mon serveur Minecraft. +Je suis donc parti d'une infrastructure VPS classique avec des daemons pour migrer vers Kubernetes, en profitant au passage de ce changement pour effectuer une montée de version majeure de Minecraft. + +## 🚛 Passage de bare metal à Kubernetes + +### ❓ Pourquoi Kubernetes ? + +Kubernetes est une plateforme d'orchestration de conteneurs qui offre de nombreux avantages pour héberger des applications, y compris des serveurs de jeux comme Minecraft. +On notera certains avantages clés comme le scalabilité, la résilience et la facilité de gestion des ressources. + +La principale raison était surtout la curiosité d'explorer Kubernetes et de voir comment il pouvait être utilisé pour héberger un serveur Minecraft. Des ressources existaient déjà pour déployer des serveurs Minecraft sur Docker, donc l'étape suivante logique était de passer à Kubernetes. + +De plus, j'ai déjà pour trajectoire de passer vers une infrastructure indépendante des cloud providers, ayant déjà un noeud Kubernetes chez moi, c'était l'occasion parfaite pour effectuer cette migration. + + +### 📜 Définition de l'infrastructure + +Pour héberger ces serveurs minecraft (ou plutôt un network entier dans ce cas) j'ai utilisé une machine avec un Intel(R) Xeon(R) CPU E3-1270 v6 @ 3.80GHz, 32Go de RAM et 256Go de stockage SSD. + +Pour déployer un network de serveurs Minecraft la solution historique était BungeeCord. Cependant depuis quelques années maintenant c'est Velocity (avec son prédécesseur Waterfall) qui est la solution favorisée. +Velocity agit comme un proxy entre les joueurs et les serveurs Minecraft, permettant une gestion efficace des connexions et une répartition de la charge entre plusieurs serveurs ou du déplacement sans déconnexion d'instance à instance au sein du même network. + +Derrière Velocity, j'ai déployé plusieurs serveurs Minecraft avec des rôles différents : +- Un serveur Lobby : Le point d'entrée principal pour les joueurs, où ils peuvent se préparer avant de rejoindre d'autres serveurs. +- Un serveur Survival : Un serveur dédié au mode survie + +Pour l'instant rien de plus mais la configuration reste flexible pour ajouter d'autres serveurs à l'avenir. + +### 🖥️ Déploiement + +Pour le déploiement toutes les ressources utilisées sont disponibles sur [mon repo GitHub](https://github.com/Proxyfil/Deployments/tree/main/minecraft). + +Chaque serveur est déployé en tant que StatefulSet Kubernetes et expose un port particulier pour la communication avec les clients Minecraft. + +Tout le stockage se fait directement sur le filesystem du noeud hôte via des PersistentVolumes liés à des PersistentVolumeClaims. Il est vrai que cette approche n'est pas idéale en cas de scaling horizontal mais pour un usage personnel c'est suffisant. +Une amélioration future pourrait être d'utiliser un stockage en réseau comme NFS ou Ceph pour permettre une meilleure flexibilité. + +Chaque serveur utilise une image Docker personnalisée basée sur une image Minecraft existante, avec des configurations spécifiques pour chaque type de serveur. (Merci à [itzg](https://github.com/itzg/docker-minecraft-server) qui produit un travail considérable avec les autres contributeurs !) + +### 🔐 Problématique de connection + +Minecraft par défaut utilise le port 25565 pour les connexions entrantes. Cependant Kubernetes ne permet pas d'exposer directement le port 25565 via un LoadBalancer ou un Ingress. + +Pour contourner ce problème j'ai déployé un conteneur Nginx en tant que LoadBalancer qui redirige le trafic entrant sur le port 25565 vers le port qui est exposé par le service Kubernetes. + +## ⬆️ Améliorations + +### 🚀 Migration vers la version 1.21.10 + +En plus de la migration vers Kubernetes, j'ai profité de l'occasion pour effectuer une montée de version majeure de Minecraft, passant de la version 1.20.4 à la version 1.21.10. + +Cette dernière version étant très récente (à l'heure où j'écris ces lignes), j'ai dû m'assurer que tous les plugins utilisés étaient compatibles avec cette version. + +Heureusement, la plupart des plugins populaires avaient déjà été mis à jour pour être compatibles avec la version 1.21.x, mais j'ai dû faire quelques ajustements mineurs dans la configuration de certains plugins pour garantir une compatibilité totale. + +Malgré ceci le déploiement reste assez instable d'une version mineure à une autre, j'imagine que cela s'améliorera lorsque les mises à jour passeront en version complète et non plus en bêta ou alpha. + +### 📊 Monitoring + +Pour surveiller les performances et la santé de mon serveur Minecraft, j'ai intégré des outils de monitoring dans mon cluster Kubernetes. J'utilise Prometheus pour collecter les métriques et Grafana pour visualiser ces données. + +Cela me permet de suivre l'utilisation des ressources, la latence des connexions et d'autres indicateurs clés de performance, ce qui est crucial pour assurer une expérience de jeu fluide pour les joueurs. +Toutes les ressources de déploiement sont disponibles sur [mon repo GitHub](https://github.com/Proxyfil/Deployments/tree/main/monitoring/prometheus) avec les configurations nécessaires pour déployer Prometheus dans Kubernetes. + +J'ai aussi ajouté un dashboard Grafana spécifique pour Minecraft, qui affiche des informations telles que le nombre de joueurs connectés, l'utilisation de la mémoire et du CPU, et d'autres métriques pertinentes. +Avec cela j'ai aussi un Node Exporter pour surveiller l'état du noeud hôte. + +## 📅 Prochains objectifs + +Maintenant que les choses sont stables, j'ai quelques idées pour améliorer encore mon déploiement Minecraft sur Kubernetes. +On pourrait passer sur du stockage provisionné, améliorer le monitoring avec des alertes ou ajouter des metrics avec des plugins. + +Mais pour l'instant je suis assez satisfait de la configuration actuelle et je profite pleinement de mon serveur Minecraft hébergé sur Kubernetes ! + +À bientôt 🫡 \ No newline at end of file