Initial commit: blog IA qu'à...

This commit is contained in:
Paul Atlan 2026-04-15 01:00:56 +02:00
commit 72641942b8
29 changed files with 1097 additions and 0 deletions

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
# build
dist/
.astro/
# deps
node_modules/
# env
.env
.env.*
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
# ── Stage 1 : Build Astro ──
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── Stage 2 : Serve static files ──
FROM nginx:alpine
# Config nginx optimisée pour un site statique
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copier le build Astro
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

55
README.md Normal file
View File

@ -0,0 +1,55 @@
# IA qu'à... — Blog
Blog statique personnel sur l'IA en PME.
## Stack
- **Astro** — Générateur de site statique
- **Tailwind CSS** — Styles utilitaires
- **MDX** — Articles en Markdown enrichi
- **Docker + nginx** — Hébergé sur Cloudbreak (Hetzner), derrière Caddy
## Démarrage
```bash
npm install
npm run dev # Serveur local → http://localhost:4321
npm run build # Build de production → dist/
npm run preview # Prévisualiser le build
```
## Structure
```
src/
├── content/blog/ ← Articles en MDX
├── components/ ← Composants Astro (Navbar, Footer, SeriesNav...)
├── layouts/ ← Layouts (BaseLayout, BlogPost)
├── pages/ ← Pages (index, à-propos, séries)
└── styles/ ← CSS global + Tailwind
```
## Écrire un article
Créer un fichier `.mdx` dans `src/content/blog/` :
```yaml
---
title: "Mon titre"
description: "Description courte"
pubDate: 2026-04-13
category: "Outils IA" # ou "Organisation & process", "Retours terrain", "Veille & tendances"
series: # optionnel
name: "Nom de la série"
part: 1
total: 4
---
```
## Déploiement
```bash
# Sur Cloudbreak :
cd /opt/ia-qua-blog # adapter le chemin
sudo bash deploy.sh # git pull + docker build + up
```

14
astro.config.mjs Normal file
View File

@ -0,0 +1,14 @@
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import tailwind from "@astrojs/tailwind";
export default defineConfig({
site: "https://ia-qua.fr", // À changer si autre domaine
integrations: [mdx(), sitemap(), tailwind()],
markdown: {
shikiConfig: {
theme: "github-dark",
},
},
});

45
deploy.sh Normal file
View File

@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
# ─── deploy.sh — Blog IA qu'à... ───
# Usage : sudo bash deploy.sh
# Depuis le répertoire du repo sur Cloudbreak
APP_NAME="ia-qua-blog"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "═══════════════════════════════════"
echo " Déploiement : $APP_NAME"
echo "═══════════════════════════════════"
cd "$SCRIPT_DIR"
# 1. Pull les dernières modifs
echo ""
echo "→ Git pull..."
git pull
# 2. Build + restart
echo ""
echo "→ Build & restart containers..."
docker compose down --remove-orphans 2>/dev/null || true
docker compose build --no-cache
docker compose up -d
# 3. Attente + status
echo ""
echo "→ Attente 10s..."
sleep 10
echo ""
echo "→ Status :"
docker compose ps
echo ""
echo "→ Logs récents :"
docker compose logs --tail=20
echo ""
echo "═══════════════════════════════════"
echo "$APP_NAME déployé"
echo "═══════════════════════════════════"

13
docker-compose.yml Normal file
View File

@ -0,0 +1,13 @@
services:
blog:
build: .
container_name: ia-qua-blog
restart: unless-stopped
ports:
- "8090:80"
networks:
- caddy-public
networks:
caddy-public:
external: true

2
netlify.toml Normal file
View File

@ -0,0 +1,2 @@
# Ce fichier n'est plus utilisé — le blog est hébergé sur Cloudbreak (Hetzner).
# À supprimer du repo.

26
nginx.conf Normal file
View File

@ -0,0 +1,26 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Cache long pour les assets (JS, CSS, images, fonts)
location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback Astro génère du statique, mais au cas
location / {
try_files $uri $uri/ /index.html;
}
# Pas de log pour favicon/robots
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
# Compression gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 256;
}

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "ia-qua-blog",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"astro": "^5.0.0",
"@astrojs/mdx": "^4.0.0",
"@astrojs/sitemap": "^3.0.0",
"@astrojs/tailwind": "^6.0.0",
"tailwindcss": "^4.0.0"
}
}

4
public/favicon.svg Normal file
View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#1B2A4A"/>
<text x="16" y="22" text-anchor="middle" font-family="Georgia, serif" font-weight="bold" font-size="16" fill="#6B8F71">IA</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View File

@ -0,0 +1,40 @@
---
import SeriesBadge from "./SeriesBadge.astro";
interface Props {
title: string;
description: string;
pubDate: Date;
category: string;
heroImage?: string;
slug: string;
series?: { name: string; part: number; total: number };
}
const { title, description, pubDate, category, heroImage, slug, series } = Astro.props;
const formattedDate = pubDate.toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
});
---
<article class="bg-white rounded-xl border border-border overflow-hidden transition-all hover:-translate-y-1 hover:shadow-lg hover:shadow-blue-night/5">
<a href={`/blog/${slug}`} class="no-underline block">
{heroImage ? (
<img src={heroImage} alt={title} class="w-full h-48 object-cover" />
) : (
<div class="w-full h-48 bg-gradient-to-br from-blue-night to-blue-mid flex items-center justify-center">
<span class="text-white/10 text-6xl">&#9673;</span>
</div>
)}
<div class="p-5">
{series && <div class="mb-2"><SeriesBadge name={series.name} part={series.part} total={series.total} /></div>}
<span class="text-xs font-semibold text-sage uppercase tracking-wide">{category}</span>
<h3 class="font-heading text-blue-night text-lg font-semibold leading-snug mt-1 mb-2">{title}</h3>
<p class="text-sm text-text-light line-clamp-2 mb-3">{description}</p>
<span class="text-xs text-text-light">{formattedDate}</span>
</div>
</a>
</article>

View File

@ -0,0 +1,13 @@
---
const year = new Date().getFullYear();
---
<footer class="max-w-6xl mx-auto mt-16 px-6 py-8 border-t border-border text-center text-sm text-text-light">
<p>
<strong class="font-heading text-blue-night">IA qu'à...</strong> — Journal de bord d'un DSI en PME face à l'IA
</p>
<p class="mt-2">
&copy; {year} — Fait avec <a href="https://astro.build" class="text-sage no-underline hover:underline">Astro</a>
&middot; Self-hosted
</p>
</footer>

View File

@ -0,0 +1,67 @@
---
const currentPath = Astro.url.pathname;
const links = [
{ href: "/", label: "Accueil" },
{ href: "/categorie/outils-ia", label: "Outils IA" },
{ href: "/categorie/organisation", label: "Organisation" },
{ href: "/categorie/retours-terrain", label: "Retours terrain" },
{ href: "/series", label: "Séries" },
{ href: "/a-propos", label: "À propos" },
];
---
<nav class="bg-white border-b border-border sticky top-0 z-50">
<div class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="/" class="font-heading font-extrabold text-xl text-blue-night no-underline">
IA qu'à<span class="text-sage">...</span>
</a>
<!-- Desktop -->
<ul class="hidden md:flex gap-8 list-none">
{links.map((link) => (
<li>
<a
href={link.href}
class:list={[
"text-sm font-medium no-underline transition-colors",
currentPath === link.href
? "text-blue-night border-b-2 border-sage pb-0.5"
: "text-text-light hover:text-blue-night",
]}
>
{link.label}
</a>
</li>
))}
</ul>
<!-- Mobile toggle -->
<button id="menu-toggle" class="md:hidden text-blue-night" aria-label="Menu">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
</div>
<!-- Mobile menu -->
<div id="mobile-menu" class="hidden md:hidden border-t border-border px-6 py-4">
<ul class="flex flex-col gap-4 list-none">
{links.map((link) => (
<li>
<a href={link.href} class="text-sm font-medium text-text-light no-underline hover:text-blue-night">
{link.label}
</a>
</li>
))}
</ul>
</div>
</nav>
<script>
document.getElementById("menu-toggle")?.addEventListener("click", () => {
document.getElementById("mobile-menu")?.classList.toggle("hidden");
});
</script>

View File

@ -0,0 +1,23 @@
---
interface Props {
name: string;
part: number;
total: number;
}
const { name, part, total } = Astro.props;
const dots = Array.from({ length: total }, (_, i) => i < part);
---
<div class="inline-flex items-center gap-2 bg-sage-light text-sage text-xs font-semibold px-3 py-1 rounded-full">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
<span>Série : {name}</span>
<div class="flex gap-0.5 ml-1">
{dots.map((filled) => (
<div class:list={["w-1.5 h-1.5 rounded-full bg-sage", filled ? "opacity-100" : "opacity-30"]} />
))}
</div>
</div>

View File

@ -0,0 +1,73 @@
---
/**
* SeriesNav — navigation inter-articles dans une série.
* Affiche la liste ordonnée des articles de la série,
* avec un indicateur visuel sur l'article courant.
*/
import { getCollection } from "astro:content";
interface Props {
seriesName: string;
currentPart: number;
}
const { seriesName, currentPart } = Astro.props;
// Récupère tous les articles de la même série
const allPosts = await getCollection("blog", ({ data }) => {
return data.series?.name === seriesName && !data.draft;
});
const seriesPosts = allPosts.sort(
(a, b) => (a.data.series?.part ?? 0) - (b.data.series?.part ?? 0)
);
---
{seriesPosts.length > 1 && (
<aside class="my-8 p-6 bg-white border border-border rounded-xl">
<h3 class="font-heading text-blue-night text-lg font-semibold mb-1">
Série : {seriesName}
</h3>
<p class="text-text-light text-sm mb-4">
Article {currentPart} sur {seriesPosts.length}
</p>
<div class="w-full h-1 bg-border rounded-full mb-4 overflow-hidden">
<div
class="h-full bg-sage rounded-full transition-all"
style={`width: ${(currentPart / seriesPosts.length) * 100}%`}
/>
</div>
<ol class="list-none space-y-2">
{seriesPosts.map((post) => {
const isCurrent = post.data.series?.part === currentPart;
return (
<li>
<a
href={`/blog/${post.id}`}
class:list={[
"flex items-center gap-3 py-2 px-3 rounded-lg text-sm no-underline transition-colors",
isCurrent
? "bg-sage-light text-sage font-semibold"
: "text-text-light hover:bg-sage-light/50 hover:text-blue-night",
]}
>
<span
class:list={[
"flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold",
isCurrent
? "bg-sage text-white"
: "bg-border text-text-light",
]}
>
{post.data.series?.part}
</span>
<span>{post.data.title}</span>
</a>
</li>
);
})}
</ol>
</aside>
)}

View File

@ -0,0 +1,28 @@
---
title: "3 mois avec Copilot : le bilan honnête"
description: "On l'a déployé pour 5 utilisateurs. Voici les vrais chiffres, pas ceux du commercial Microsoft."
pubDate: 2026-04-02
category: "Retours terrain"
---
## Le setup
5 licences Microsoft 365 Copilot, déployées pour des profils variés : un commercial, deux chargés d'affaires, une personne à l'ADV, et moi.
Budget : 30€/utilisateur/mois, soit 150€/mois pour le pilote.
## Ce qui marche vraiment
Le résumé de mails et de réunions Teams. C'est le use case où le ROI est le plus immédiat et le moins discutable. Quand vous revenez de vacances avec 200 mails, avoir un résumé par fil de discussion, c'est un vrai gain.
## Ce qui marche... moyen
La génération de documents Word et PowerPoint. C'est impressionnant en démo, mais en pratique on passe presque autant de temps à corriger qu'à écrire from scratch.
## Ce qui ne marche pas
Excel. Le commercial espérait que Copilot allait lui faire ses tableaux croisés dynamiques. En réalité, il faut déjà savoir ce qu'on veut pour que l'IA le fasse.
## Le verdict
3 licences sur 5 renouvelées. Les deux autres n'ont pas trouvé de use case qui justifie 30€/mois. C'est honnête.

View File

@ -0,0 +1,36 @@
---
title: "Peut-on faire rédiger un CdC par GPT-4 ? (1/4)"
description: "Première tentative : on donne un brief brut à GPT-4 et on regarde ce qui sort. Spoiler : c'est à la fois bluffant et inutilisable."
pubDate: 2026-03-25
category: "Outils IA"
series:
name: "IA et cahiers des charges"
part: 1
total: 4
---
## Le contexte
On avait un projet interne à spécifier : un outil de reporting pour l'équipe commerciale. Le genre de CdC qu'on écrit habituellement en 2-3 jours, avec beaucoup d'allers-retours.
L'idée : et si on demandait à GPT-4 de produire une première version ?
## Le protocole
J'ai donné à GPT-4 un brief d'une page. Pas un prompt sophistiqué — juste ce que j'aurais envoyé par mail à un prestataire. Et j'ai demandé un cahier des charges structuré.
## Ce qui en est sorti
Le résultat faisait 12 pages. Bien structuré, avec des sections qu'on retrouve dans un vrai CdC : contexte, objectifs, périmètre fonctionnel, contraintes techniques, planning.
Mais en y regardant de plus près...
## Les problèmes
Le document était **générique**. Pas faux, mais pas spécifique non plus. Les sections "contraintes techniques" mentionnaient des bonnes pratiques générales, pas *nos* contraintes réelles. Le planning était fantaisiste.
C'est le piège classique : ça a l'air pro, ça utilise le bon vocabulaire, mais c'est du remplissage structuré.
## La suite
Dans le prochain article, on teste une approche différente : au lieu de demander un CdC complet, on utilise l'IA comme *sparring partner* pour challenger notre propre brief.

View File

@ -0,0 +1,22 @@
---
title: "L'IA comme sparring partner pour un CdC (2/4)"
description: "On a changé d'approche : au lieu de lui faire écrire le CdC, on lui a fait challenger le nôtre. Les résultats sont bien meilleurs."
pubDate: 2026-04-12
category: "Outils IA"
series:
name: "IA et cahiers des charges"
part: 2
total: 4
---
## Le pivot
Après l'échec relatif de la première tentative, j'ai changé de stratégie. Au lieu de demander à l'IA d'écrire, je lui ai demandé de **lire et critiquer**.
J'ai repris notre CdC interne (celui qu'on avait rédigé nous-mêmes) et je l'ai soumis à Claude avec cette consigne : *"Tu es un prestataire qui reçoit ce cahier des charges. Quelles questions poses-tu avant de chiffrer ?"*
## Ce que ça a donné
15 questions. Dont 8 qu'on ne s'était jamais posées.
*À suivre dans la partie 3...*

View File

@ -0,0 +1,22 @@
---
title: "Comment j'ai convaincu le Codir de tester Claude"
description: "Spoiler : ce n'était pas gagné. Retour sur les arguments qui ont marché et ceux qui ont fait un flop monumental."
pubDate: 2026-04-08
category: "Organisation & process"
---
## Le contexte
Quand vous êtes DSI d'une PME et que vous dites « on devrait tester l'IA », vous obtenez deux types de réactions. La première : « Ah oui, comme ChatGPT ? Mon fils l'utilise pour ses devoirs. » La deuxième : « C'est pas un peu dangereux pour la confidentialité ? »
Aucune des deux ne vous aide à obtenir un budget.
## Ce qui n'a PAS marché
Le pitch techno. Parler de LLMs, de tokens, de fine-tuning — les yeux du DG se sont vitrifiés en 30 secondes.
## Ce qui a marché
Un tableau avec trois colonnes : la tâche, le temps qu'elle prend aujourd'hui, le temps estimé avec l'outil. Pas de promesses, juste des hypothèses à tester.
Et surtout : proposer un pilote de 3 mois, avec 5 utilisateurs, et un budget de 200€/mois. Le risque était tellement faible que dire non aurait été plus difficile que dire oui.

30
src/content/config.ts Normal file
View File

@ -0,0 +1,30 @@
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content",
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
category: z.enum([
"Outils IA",
"Organisation & process",
"Retours terrain",
"Veille & tendances",
]),
// Série (optionnel)
series: z
.object({
name: z.string(),
part: z.number(),
total: z.number(),
})
.optional(),
// Draft
draft: z.boolean().default(false),
}),
});
export const collections = { blog };

View File

@ -0,0 +1,63 @@
---
import "../styles/global.css";
import Navbar from "../components/Navbar.astro";
import Footer from "../components/Footer.astro";
interface Props {
title: string;
description?: string;
image?: string;
}
const {
title,
description = "Journal de bord d'un DSI en PME face à l'IA",
image = "/og-default.png",
} = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
---
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="sitemap" href="/sitemap-index.xml" />
<!-- SEO -->
<title>{title} | IA qu'à...</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
<!-- Open Graph -->
<meta property="og:type" content="article" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(image, Astro.site)} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:locale" content="fr_FR" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={new URL(image, Astro.site)} />
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,600;0,9..144,800;1,9..144,400&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body class="min-h-screen flex flex-col">
<Navbar />
<main class="flex-1">
<slot />
</main>
<Footer />
</body>
</html>

View File

@ -0,0 +1,77 @@
---
import BaseLayout from "./BaseLayout.astro";
import SeriesBadge from "../components/SeriesBadge.astro";
import SeriesNav from "../components/SeriesNav.astro";
interface Props {
title: string;
description: string;
pubDate: Date;
updatedDate?: Date;
heroImage?: string;
category: string;
series?: { name: string; part: number; total: number };
}
const { title, description, pubDate, updatedDate, heroImage, category, series } =
Astro.props;
const formattedDate = pubDate.toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
});
---
<BaseLayout title={title} description={description} image={heroImage}>
<article class="max-w-3xl mx-auto px-6 py-12">
<!-- Meta -->
<div class="mb-6">
{series && (
<div class="mb-3">
<SeriesBadge name={series.name} part={series.part} total={series.total} />
</div>
)}
<span class="text-xs font-semibold text-sage uppercase tracking-wide">
{category}
</span>
<h1 class="font-heading text-blue-night text-4xl font-extrabold leading-tight mt-2 mb-3">
{title}
</h1>
<p class="text-text-light">{description}</p>
<div class="flex items-center gap-2 mt-4 text-sm text-text-light">
<time datetime={pubDate.toISOString()}>{formattedDate}</time>
{updatedDate && (
<>
<span>&middot;</span>
<span>
Mis à jour le{" "}
{updatedDate.toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
})}
</span>
</>
)}
</div>
</div>
<!-- Hero image -->
{heroImage && (
<img
src={heroImage}
alt={title}
class="w-full rounded-xl mb-8"
/>
)}
<!-- Contenu -->
<div class="prose">
<slot />
</div>
<!-- Navigation série -->
{series && <SeriesNav seriesName={series.name} currentPart={series.part} />}
</article>
</BaseLayout>

55
src/pages/a-propos.astro Normal file
View File

@ -0,0 +1,55 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
---
<BaseLayout title="À propos">
<article class="max-w-3xl mx-auto px-6 py-16">
<h1 class="font-heading text-blue-night text-4xl font-extrabold leading-tight mb-6">
À propos
</h1>
<div class="prose">
<p>
Je suis DSI d'une PME. Pas une startup, pas un grand groupe du CAC 40 —
une PME, avec ses contraintes de budget, ses priorités qui changent,
et ses utilisateurs qui veulent que « ça marche, c'est tout ».
</p>
<p>
Depuis l'arrivée des LLMs et de l'IA générative, je teste.
Je bidouille. Je plante. Et parfois, ça marche.
</p>
<h2>Pourquoi ce blog ?</h2>
<p>
Parce que la majorité des contenus sur l'IA sont écrits par des gens
qui vendent de l'IA. Ici, c'est l'inverse : c'est écrit par quelqu'un
qui l'achète — ou qui essaie de ne pas l'acheter quand c'est inutile.
</p>
<p>
Ce blog est un journal de bord. J'y raconte ce que je teste dans mon
contexte réel : les outils, les process, les réactions de l'équipe,
les coûts, les surprises bonnes et mauvaises. Pas de théorie, pas de
« 10 prompts magiques » — du terrain.
</p>
<h2>Ce que vous trouverez ici</h2>
<p>
Des articles de fond sur l'IA en PME : outils testés, retours
d'expérience, réflexions sur l'organisation. Certains articles
s'inscrivent dans des séries thématiques pour suivre un sujet
dans la durée (déploiement d'un outil, test grandeur nature, etc.).
</p>
<h2>Contact</h2>
<p>
<!-- TODO: ajouter un formulaire de contact ou un lien vers un réseau -->
Vous pouvez me retrouver sur LinkedIn. Le lien arrive bientôt.
</p>
</div>
</article>
</BaseLayout>

View File

@ -0,0 +1,27 @@
---
import { getCollection } from "astro:content";
import BlogPost from "../../layouts/BlogPost.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { slug: post.id },
props: post,
}));
}
const post = Astro.props;
const { Content } = await post.render();
---
<BlogPost
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
updatedDate={post.data.updatedDate}
heroImage={post.data.heroImage}
category={post.data.category}
series={post.data.series}
>
<Content />
</BlogPost>

112
src/pages/index.astro Normal file
View File

@ -0,0 +1,112 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import ArticleCard from "../components/ArticleCard.astro";
import { getCollection } from "astro:content";
const allPosts = await getCollection("blog", ({ data }) => !data.draft);
const posts = allPosts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
const categories = [
"Outils IA",
"Organisation & process",
"Retours terrain",
"Veille & tendances",
];
// Séries en cours (dédupliquées)
const seriesMap = new Map<string, { name: string; count: number; total: number }>();
for (const post of posts) {
if (post.data.series) {
const s = post.data.series;
const existing = seriesMap.get(s.name);
if (!existing) {
seriesMap.set(s.name, { name: s.name, count: 1, total: s.total });
} else {
existing.count++;
}
}
}
const seriesList = Array.from(seriesMap.values());
---
<BaseLayout title="Accueil">
<!-- Hero -->
<section class="max-w-3xl mx-auto text-center px-6 pt-16 pb-10">
<h1 class="font-heading text-blue-night text-4xl md:text-5xl font-extrabold leading-tight mb-4">
L'IA en PME,<br />sans bullshit.
</h1>
<p class="text-lg text-text-light max-w-xl mx-auto">
Journal de bord d'un DSI qui teste, bidouille, plante et recommence.
Ce qui marche vraiment, ce qui ne marche pas, et pourquoi.
</p>
</section>
<!-- Catégories -->
<div class="flex justify-center gap-3 flex-wrap px-6 mb-10">
{categories.map((cat) => (
<a
href={`/categorie/${cat.toLowerCase().replace(/ & /g, "-").replace(/ /g, "-")}`}
class="px-4 py-1.5 rounded-full text-xs font-medium border border-border bg-white text-text-light no-underline hover:bg-sage-light hover:border-sage hover:text-sage transition-colors"
>
{cat}
</a>
))}
</div>
<!-- Articles -->
<section class="max-w-6xl mx-auto px-6">
<div class="flex items-center justify-between mb-6">
<h2 class="font-heading text-xl text-blue-night">Articles récents</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post) => (
<ArticleCard
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
category={post.data.category}
heroImage={post.data.heroImage}
slug={post.id}
series={post.data.series}
/>
))}
</div>
</section>
<!-- Séries en cours -->
{seriesList.length > 0 && (
<section class="max-w-6xl mx-auto px-6 mt-16">
<div class="flex items-center justify-between mb-6">
<h2 class="font-heading text-xl text-blue-night">Séries en cours</h2>
<a href="/series" class="text-sm text-sage no-underline font-medium hover:underline">
Toutes les séries &rarr;
</a>
</div>
{seriesList.map((s) => (
<a
href={`/series/${s.name.toLowerCase().replace(/ /g, "-")}`}
class="block bg-white border border-border rounded-xl p-5 mb-3 no-underline hover:translate-x-1 transition-transform"
>
<div class="flex items-center justify-between">
<div>
<h3 class="font-heading text-blue-night text-base font-semibold">{s.name}</h3>
</div>
<div class="flex items-center gap-3">
<div class="w-28 h-1 bg-border rounded-full overflow-hidden">
<div
class="h-full bg-sage rounded-full"
style={`width: ${(s.count / s.total) * 100}%`}
/>
</div>
<span class="text-xs text-text-light whitespace-nowrap">
{s.count} / {s.total} articles
</span>
</div>
</div>
</a>
))}
</section>
)}
</BaseLayout>

94
src/pages/series.astro Normal file
View File

@ -0,0 +1,94 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import { getCollection } from "astro:content";
const allPosts = await getCollection("blog", ({ data }) => !data.draft);
// Regrouper par série
const seriesMap = new Map<
string,
{ name: string; posts: typeof allPosts; total: number }
>();
for (const post of allPosts) {
if (post.data.series) {
const s = post.data.series;
const existing = seriesMap.get(s.name);
if (!existing) {
seriesMap.set(s.name, { name: s.name, posts: [post], total: s.total });
} else {
existing.posts.push(post);
}
}
}
const seriesList = Array.from(seriesMap.values()).map((s) => ({
...s,
posts: s.posts.sort(
(a, b) => (a.data.series?.part ?? 0) - (b.data.series?.part ?? 0)
),
}));
---
<BaseLayout title="Séries">
<section class="max-w-3xl mx-auto px-6 py-16">
<h1 class="font-heading text-blue-night text-4xl font-extrabold leading-tight mb-3">
Séries
</h1>
<p class="text-text-light mb-10">
Des articles qui se suivent pour explorer un sujet en profondeur.
</p>
{seriesList.length === 0 && (
<p class="text-text-light italic">Aucune série pour le moment.</p>
)}
{seriesList.map((series) => (
<div class="bg-white border border-border rounded-xl p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="font-heading text-blue-night text-xl font-semibold">
{series.name}
</h2>
<span class="text-xs text-text-light">
{series.posts.length} / {series.total} articles
</span>
</div>
<div class="w-full h-1 bg-border rounded-full mb-5 overflow-hidden">
<div
class="h-full bg-sage rounded-full"
style={`width: ${(series.posts.length / series.total) * 100}%`}
/>
</div>
<ol class="list-none space-y-2">
{series.posts.map((post) => (
<li>
<a
href={`/blog/${post.id}`}
class="flex items-center gap-3 py-2 px-3 rounded-lg text-sm text-text-light no-underline hover:bg-sage-light/50 hover:text-blue-night transition-colors"
>
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-sage text-white flex items-center justify-center text-xs font-bold">
{post.data.series?.part}
</span>
<span>{post.data.title}</span>
</a>
</li>
))}
{/* Articles pas encore publiés */}
{Array.from(
{ length: series.total - series.posts.length },
(_, i) => (
<li class="flex items-center gap-3 py-2 px-3 text-sm text-text-light/50">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-border text-text-light/50 flex items-center justify-center text-xs font-bold">
{series.posts.length + i + 1}
</span>
<span class="italic">À venir...</span>
</li>
)
)}
</ol>
</div>
))}
</section>
</BaseLayout>

68
src/styles/global.css Normal file
View File

@ -0,0 +1,68 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url("https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300;0,9..144,600;0,9..144,800;1,9..144,400&family=Inter:wght@400;500;600&display=swap");
@layer base {
body {
@apply bg-bg-warm text-text-dark font-body leading-relaxed;
}
h1,
h2,
h3,
h4 {
@apply font-heading text-blue-night;
}
}
/* ─── Prose (articles MDX) ─── */
.prose {
@apply max-w-none text-text-dark leading-relaxed;
}
.prose h2 {
@apply font-heading text-blue-night text-2xl font-semibold mt-10 mb-4;
}
.prose h3 {
@apply font-heading text-blue-night text-xl font-semibold mt-8 mb-3;
}
.prose p {
@apply mb-5;
}
.prose a {
@apply text-sage underline underline-offset-2 hover:text-blue-night transition-colors;
}
.prose blockquote {
@apply border-l-4 border-sage pl-4 italic text-text-light my-6;
}
.prose code {
@apply bg-sage-light text-blue-night px-1.5 py-0.5 rounded text-sm;
}
.prose pre {
@apply bg-blue-night text-white rounded-lg p-4 overflow-x-auto my-6;
}
.prose pre code {
@apply bg-transparent text-white p-0;
}
.prose img {
@apply rounded-lg my-6;
}
.prose ul,
.prose ol {
@apply my-4 pl-6;
}
.prose li {
@apply mb-2;
}

23
tailwind.config.mjs Normal file
View File

@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: {
extend: {
colors: {
"blue-night": "#1B2A4A",
"blue-mid": "#2d4a7a",
sage: "#6B8F71",
"sage-light": "#E8F0E9",
"bg-warm": "#FAFAF7",
"text-dark": "#2D2D2D",
"text-light": "#6B6B6B",
border: "#E5E5E0",
},
fontFamily: {
heading: ["Fraunces", "Georgia", "serif"],
body: ["Inter", "-apple-system", "BlinkMacSystemFont", "sans-serif"],
},
},
},
plugins: [],
};

9
tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}