<!--
  canonical-url: https://www.avonture.be/fr/blog/docusaurus-typo-report-component
  published:     2026-10-29
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus

> Construisez un widget de feedback lecteur sécurisé et SSR-safe pour votre blog Docusaurus — sélection de texte, 4 types de feedback, API PHP avec nonce HMAC, rate limiting, déduplication, dashboard admin et notifications par email.

---
![Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus](/img/v2/docusaurus_typos.webp)

**TL;DR**

Un lecteur repère un problème dans votre article — une faute de frappe, une erreur factuelle, une commande obsolète. Il n'a aucun moyen de vous le dire. Il passe à autre chose. Vous n'en saurez jamais rien. Cet article construit de A à Z un widget de feedback à sens unique : le lecteur sélectionne un passage, un popover apparaît avec quatre types de problème (faute de frappe, information incorrecte, contenu obsolète, suggestion), il ajoute éventuellement des détails, et le signalement atterrit dans un fichier JSON sur votre serveur avec une notification par email. L'API PHP est durcie avec des nonces HMAC, un honeypot, du rate limiting et de la déduplication. Le composant React est SSR-safe, utilise une machine à états propre et limite les envois via `localStorage`. Un dashboard admin associé, sur `/typo-dashboard`, vous permet de relire tous les signalements par type. Pas de base de données, pas de service tiers, aucune dépendance npm au-delà de ce que Docusaurus embarque déjà.

Votre blog est votre vitrine publique. Une faute de frappe qui traîne, une commande qui ne fonctionne plus, une API qui a changé — chaque lecteur qui tombe sur ce paragraphe vous coûte en crédibilité. Le problème, c'est que la plupart des lecteurs ne prendront pas la peine d'ouvrir une issue GitHub ou d'envoyer un email. Mais si vous rendez la chose vraiment triviale — un clic, là où ils se trouvent — un nombre surprenant le fera.

Cet article construit ce mécanisme de bout en bout.

**Quick Jump:** [Démonstration](#see-it-in-action) | [Créer le composant](#create-the-component)

## Démonstration {#see-it-in-action}

Le widget est actif sur cette page même. **Sélectionnez quelques mots de ce paragraphe** avec votre souris (ou votre doigt sur un téléphone) : un popover apparaît juste sous la sélection, avec quatre types de feedback.

> Screenshot — https\://www\.avonture.be/blog/docusaurus-typo-report-component
>
> ![Le popover de feedback, avec ses quatre types, sous une sélection de texte](./images/typo_popover.webp)

Le lecteur en choisit un, puis un petit formulaire lui permet d'ajouter un commentaire facultatif avant l'envoi. Le formulaire le dit clairement : **c'est un signal à sens unique** — le lecteur ne laisse pas d'email, donc personne n'attend de réponse.

> Screenshot — https\://www\.avonture.be/blog/docusaurus-typo-report-component
>
> ![Le formulaire de détails, affiché une fois le type de feedback choisi](./images/typo_form.webp)

De votre côté, chaque signalement atterrit dans un fichier JSON sur le serveur, vous recevez un email, et une page protégée par token liste tout, du plus récent au plus ancien, avec des compteurs par type :

> Screenshot — https\://www\.avonture.be/typo-dashboard
>
> ![Le dashboard admin listant les signalements reçus](./images/typo_dashboard.webp)

## Comment ça marche {#how-it-works}

La fonctionnalité se compose de deux moitiés indépendantes qui reprennent le schéma utilisé par le [composant Reaction](/blog/docusaurus-reactions) (votes like / dislike) :

```plaintext
Reader selects text
  └─ mouseup / touchend fires on <article>
       └─ popover shows 4 feedback-type buttons (phase: selecting)
            └─ reader picks a type → optional details form (phase: confirming)
                 └─ POST api/typo.php
                      └─ validates nonce, honeypot, rate limits, dedup
                           └─ writes typo-data.json
                                └─ emails admin (6h cooldown)
```

* **Pas de base de données** — les signalements sont ajoutés à un fichier JSON, sous verrou de fichier.
* **Pas de service tiers** — un simple script PHP posé à côté de vos fichiers statiques.
* **Défense en profondeur** — neuf couches indépendantes bloquent les bots, les floods et les doublons (détaillées à la fin, pour les curieux).
* **SSR-safe** — le composant ne rend rien tant que le lecteur n'a pas sélectionné de texte.

***

## Partie 1 — L'API PHP {#part-1--the-php-api}

### Pourquoi PHP ? {#why-php}

Docusaurus produit un site statique. Il n'a aucun runtime côté serveur. Si vous êtes sur un hébergement mutualisé (OVH, Infomaniak, o2switch…), un petit [PHP script](/blog/php-api-tips) posé à côté de vos fichiers statiques est le backend le moins contraignant possible — pas de process Node à gérer, pas de container Docker, pas de démarrage à froid serverless.

### Structure des fichiers sur le serveur {#file-structure-on-the-server}

```tree expanded=true showJSX=false debug=false title="File structure"
public_html/
  api/
    .env                    ← secrets (never committed)
    .env.example            ← template committed to git
    .htaccess               ← protects JSON files
    typo.php                ← the API
    typo-data.json          ← reports (writable by web server)
    typo-ratelimit.json     ← counters
    typo-notifications.json ← email throttle
```

### Le script {#the-script}

L'API répond à trois requêtes : `GET` distribue un nonce (et, avec `?admin=TOKEN`, la liste complète des signalements), `POST` enregistre un signalement, `OPTIONS` répond au preflight CORS. Chaque `POST` traverse les neuf couches décrites dans la section sécurité, à la fin de cet article, avant que quoi que ce soit ne soit écrit.

**api/typo.php**

```php
<?php
declare(strict_types=1);

$_envVars = file_exists(__DIR__ . '/.env')
    ? (parse_ini_file(__DIR__ . '/.env') ?: [])
    : [];

define('ADMIN_EMAIL',            $_envVars['ADMIN_EMAIL']    ?? '');
define('ADMIN_TOKEN',            $_envVars['ADMIN_TOKEN']    ?? '');
define('IP_HASH_SALT',           $_envVars['IP_HASH_SALT']   ?? '');
define('NONCE_SECRET',           $_envVars['NONCE_SECRET']   ?? '');
define('SITE_URL',               'https://www.yourdomain.com');

define('NONCE_COOLDOWN_SECONDS', 900);
define('NOTIFY_COOLDOWN_SECONDS', 21600);
define('GLOBAL_WINDOW',   60);
define('GLOBAL_MAX',      20);
define('IP_HOURLY_MAX',   10);
define('IP_ARTICLE_MAX',  3);

// ── CORS ──────────────────────────────────────────────────────────────────────
$allowedOrigins = [SITE_URL, 'http://localhost:3000'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '' && !in_array($origin, $allowedOrigins, true)) {
    http_response_code(403); exit;
}

header('Content-Type: application/json; charset=utf-8');
if ($origin !== '') {
    header("Access-Control-Allow-Origin: $origin");
    header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
    header('Access-Control-Allow-Headers: Content-Type');
    header('Vary: Origin');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }

// ── Shared helpers ────────────────────────────────────────────────────────────
function loadData(string $file): array {
    if (!file_exists($file)) return [];
    return json_decode(file_get_contents($file), true) ?: [];
}

function saveData(string $file, array $data): void {
    $fp = fopen($file, 'c+');
    if (!$fp) return;
    if (flock($fp, LOCK_EX)) {
        ftruncate($fp, 0); rewind($fp);
        fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
        flock($fp, LOCK_UN);
    }
    fclose($fp);
}

function sanitizeSlug(string $raw): string {
    return substr(preg_replace('/[^a-z0-9\-\/]/', '', strtolower(trim($raw))), 0, 200);
}

function jsonError(int $code, string $message): never {
    http_response_code($code);
    echo json_encode(['error' => $message]);
    exit;
}

// ── Nonce ─────────────────────────────────────────────────────────────────────
function makeNonce(int $window): string {
    return hash_hmac('sha256', $window . '|typo-report', NONCE_SECRET);
}

function validateNonce(string $candidate): bool {
    $now = (int) floor(time() / NONCE_COOLDOWN_SECONDS);
    return hash_equals(makeNonce($now), $candidate)
        || hash_equals(makeNonce($now - 1), $candidate);
}

// ── Input validation ──────────────────────────────────────────────────────────
function validateAndSanitize(array $body): array {
    if (($body['website'] ?? '') !== '') return []; // honeypot

    $slug = sanitizeSlug($body['slug'] ?? '');
    if ($slug === '') jsonError(400, 'Missing slug');

    $text = trim($body['text'] ?? '');
    if (mb_strlen($text) < 3 || mb_strlen($text) > 150
        || !preg_match('/^\P{C}+$/u', $text)) jsonError(400, 'Invalid text');

    $allowedTypes = ['typo', 'incorrect', 'outdated', 'suggestion'];
    $type = trim($body['type'] ?? '');
    if (!in_array($type, $allowedTypes, true)) jsonError(400, 'Invalid type');

    $comment = trim($body['comment'] ?? '');
    if ($comment !== '' && (mb_strlen($comment) > 300
        || !preg_match('/^\P{C}+$/u', $comment))) jsonError(400, 'Invalid comment');

    $context = mb_substr(trim($body['context'] ?? ''), 0, 300);

    return compact('slug', 'text', 'type', 'comment', 'context');
}

// ── Rate limiting ─────────────────────────────────────────────────────────────
function checkRateLimits(string $slug): void {
    $file  = __DIR__ . '/typo-ratelimit.json';
    $ip    = $_SERVER['REMOTE_ADDR'] ?? '';
    $ipKey = hash('sha256', IP_HASH_SALT . $ip);
    $now   = time();

    $fp = fopen($file, 'c+');
    if (!$fp || !flock($fp, LOCK_EX)) { if ($fp) fclose($fp); return; }

    $raw  = stream_get_contents($fp);
    $data = $raw !== '' ? (json_decode($raw, true) ?: []) : [];

    $g = $data['__global__'] ?? ['window_start' => $now, 'count' => 0];
    if ($now - $g['window_start'] > GLOBAL_WINDOW) $g = ['window_start' => $now, 'count' => 0];
    if (++$g['count'] > GLOBAL_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); }
    $data['__global__'] = $g;

    $ipData = $data[$ipKey] ?? ['hourly' => [], 'articles' => []];
    $ipData['hourly'] = array_values(array_filter($ipData['hourly'], fn($ts) => $ts > $now - 3600));
    if (count($ipData['hourly']) >= IP_HOURLY_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); }

    $artKey  = $ipKey . '|' . $slug;
    $artData = array_values(array_filter($data[$artKey] ?? [], fn($ts) => $ts > $now - 86400));
    if (count($artData) >= IP_ARTICLE_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); }

    $ipData['hourly'][] = $now;
    $artData[]          = $now;
    $data[$ipKey]       = $ipData;
    $data[$artKey]      = $artData;

    ftruncate($fp, 0); rewind($fp);
    fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
    flock($fp, LOCK_UN); fclose($fp);
}

// ── Dedup + store ─────────────────────────────────────────────────────────────
function isDuplicate(string $slug, string $text): bool {
    $hash = hash('sha256', $slug . '|' . mb_strtolower($text));
    foreach (loadData(__DIR__ . '/typo-data.json')[$slug] ?? [] as $r) {
        if (($r['text_hash'] ?? '') === $hash) return true;
    }
    return false;
}

function storeReport(array $fields): void {
    ['slug' => $slug, 'text' => $text, 'type' => $type,
     'comment' => $comment, 'context' => $context] = $fields;

    $ip = $_SERVER['REMOTE_ADDR'] ?? '';
    $fp = fopen(__DIR__ . '/typo-data.json', 'c+');
    if (!$fp) return;
    if (flock($fp, LOCK_EX)) {
        $raw  = stream_get_contents($fp);
        $data = $raw !== '' ? (json_decode($raw, true) ?: []) : [];
        $data[$slug][] = [
            'id'        => bin2hex(random_bytes(6)),
            'type'      => $type,
            'text'      => $text,
            'text_hash' => hash('sha256', $slug . '|' . mb_strtolower($text)),
            'context'   => $context,
            'comment'   => $comment,
            'ts'        => time(),
            'ip_hash'   => hash('sha256', IP_HASH_SALT . $ip),
        ];
        ftruncate($fp, 0); rewind($fp);
        fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
        flock($fp, LOCK_UN);
    }
    fclose($fp);
}

function maybeNotifyTypo(string $slug, string $text, string $type): void {
    $file     = __DIR__ . '/typo-notifications.json';
    $throttle = loadData($file);
    if (time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS) return;

    $typeLabels = [
        'typo'       => '🔤 Typo',
        'incorrect'  => '❌ Incorrect info',
        'outdated'   => '⏰ Outdated content',
        'suggestion' => '💡 Suggestion',
    ];
    $typeLabel  = $typeLabels[$type] ?? $type;
    $articleUrl = SITE_URL . '/' . $slug;
    $subject    = "[Blog] Feedback ($type) on: $slug";
    $body       = implode("\n", [
        "A reader flagged an issue.",
        "",
        "Article : $articleUrl",
        "Type    : $typeLabel",
        "Text    : $text",
        "",
        "Review all reports: GET " . SITE_URL . "/api/typo.php?admin=TOKEN",
    ]);
    $headers = "From: noreply@yourdomain.com\r\nContent-Type: text/plain; charset=utf-8";

    if (@mail(ADMIN_EMAIL, $subject, $body, $headers)) {
        $throttle[$slug] = time();
        saveData($file, $throttle);
    }
}

// ── Router ────────────────────────────────────────────────────────────────────
$method = $_SERVER['REQUEST_METHOD'];

if ($method === 'GET') {
    if (array_key_exists('nonce', $_GET)) {
        echo json_encode(['nonce' => makeNonce((int) floor(time() / NONCE_COOLDOWN_SECONDS))]);
        exit;
    }
    if (array_key_exists('admin', $_GET)) {
        if (ADMIN_TOKEN === '' || ($_GET['admin'] ?? '') !== ADMIN_TOKEN) jsonError(403, 'Forbidden');
        echo json_encode(loadData(__DIR__ . '/typo-data.json'));
        exit;
    }
    jsonError(400, 'Missing parameter');
}

if ($method !== 'POST') jsonError(405, 'Method not allowed');

$body  = json_decode(file_get_contents('php://input'), true) ?? [];
$nonce = $body['nonce'] ?? '';
if ($nonce === '' || !validateNonce($nonce)) jsonError(403, 'Invalid nonce');

$fields = validateAndSanitize($body);
if ($fields === []) { echo json_encode(['ok' => true]); exit; }

['slug' => $slug, 'text' => $text] = $fields;

checkRateLimits($slug);

if (isDuplicate($slug, $text)) { echo json_encode(['ok' => true]); exit; }

storeReport($fields);
maybeNotifyTypo($slug, $text, $fields['type']);

echo json_encode(['ok' => true]);
```

### Protéger les fichiers JSON {#protect-the-json-files}

Sans la [règle .htaccess](/blog/apache-htaccess) livrée dans `api/.htaccess`, n'importe qui peut faire `GET https://yourdomain.com/api/typo-data.json` et lire tous les signalements.

### Les secrets {#secrets}

Le fichier `api/.env` contient deux secrets. Générez-les avec :

```bash
openssl rand -hex 32   # IP_HASH_SALT
openssl rand -hex 32   # NONCE_SECRET
```

***

## Partie 2 — Le composant React {#part-2--the-react-component}

### La sécurité SSR d'abord {#ssr-safety-first}

Docusaurus rend les pages côté serveur (Node.js) avant de les hydrater dans le navigateur. Tout code qui touche à `window`, `document` ou `localStorage` fera planter la passe SSR s'il s'exécute au niveau du module ou dans le corps du composant.

Les règles :

1. Tout accès au DOM se fait dans un `useEffect` — il ne s'exécute que dans le navigateur.
2. L'état est initialisé à `"idle"`, pas à une valeur lue depuis le navigateur.
3. Le composant retourne `null` à l'état idle — pas de DOM, pas de mismatch SSR.

### Machine à états {#state-machine}

Six phases, linéaires avec une seule bifurcation :

```plaintext
idle
  └─ user selects text → selecting
       └─ user picks a feedback type → confirming
            └─ form submitted → submitting
                 ├─ success → done
                 └─ error   → error
```

`done` et `error` affichent un badge que l'on peut fermer et qui remet l'état à `idle`. `selecting` et `confirming` repassent à `idle` si l'utilisateur clique en dehors du popover. `confirming` propose un lien « changer » qui ramène à `selecting`.

### Les helpers localStorage {#localstorage-helpers}

Avant que le popover de sélection du type n'apparaisse, deux gardes côté client s'exécutent :

1. **Rate limit** — maximum 5 envois par navigateur et par heure. Les timestamps sont stockés dans un tableau JSON dans `localStorage` sous la clé `"typo_reports"`. Les entrées de plus de 24 heures sont purgées à chaque écriture.

2. **Déduplication** — un hash FNV-1a de `slug + "|" + lowercase(text)` est stocké à côté du timestamp. Si le même texte a déjà été signalé depuis ce navigateur (quel que soit le type), le composant saute directement à `done`.

FNV-1a est utilisé plutôt que `crypto.subtle.digest` parce qu'il est synchrone, et le risque de collision est acceptable dans ce cas d'usage.

```js
function fnv1a(str) {
  let hash = 0x811c9dc5;
  for (let i = 0; i < str.length; i++) {
    hash ^= str.charCodeAt(i);
    hash = (hash * 0x01000193) >>> 0;
  }
  return hash.toString(16);
}
```

### Les types de feedback {#feedback-types}

Quatre types couvrent la grande majorité des problèmes qu'un lecteur peut remarquer :

```js
const FEEDBACK_TYPES = [
  { id: "typo",       icon: "🔤", label: "Typo" },
  { id: "incorrect",  icon: "❌", label: "Incorrect" },
  { id: "outdated",   icon: "⏰", label: "Outdated" },
  { id: "suggestion", icon: "💡", label: "Suggestion" },
];
```

Ils sont affichés dans une grille 2×2. L'API PHP valide le champ `type` contre cette même whitelist — toute autre valeur renvoie un 400.

### Capturer le contexte {#capturing-context}

Quand l'utilisateur sélectionne du texte, le composant enregistre ±100 caractères de texte environnant depuis `article.innerText`. Ce contexte est envoyé à l'API pour que vous puissiez localiser la phrase exacte sans ouvrir l'article :

```js
const fullText = article.innerText || "";
const idx = fullText.indexOf(text);
contextRef.current = idx !== -1
  ? fullText.slice(Math.max(0, idx - 100), idx + text.length + 100)
  : "";
```

### Positionnement du tooltip {#tooltip-positioning}

Le popover apparaît juste sous la sélection. `window.scrollY + rect.bottom + 8` le place 8px sous le bord inférieur du texte sélectionné. Pour éviter que le formulaire de 280px de large ne déborde du bord droit du viewport :

```js
const left = Math.min(window.scrollX + rect.left, window.innerWidth - 296);
```

### Le composant {#the-component}

**src/components/TypoReport/index.tsx**

```typescript
import {
  useState,
  useEffect,
  useRef,
  useCallback,
  type FormEvent,
  type JSX,
} from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import styles from "./styles.module.css";

const STORAGE_KEY = "typo_reports"; // [{ts, hash}]
const MAX_PER_HOUR = 5;

const FEEDBACK_TYPES = [
  { id: "typo", icon: "🔤", label: "Typo" },
  { id: "incorrect", icon: "❌", label: "Incorrect" },
  { id: "outdated", icon: "⏰", label: "Outdated" },
  { id: "suggestion", icon: "💡", label: "Suggestion" },
];

interface StoredReport {
  ts: number;
  hash: string;
}

// FNV-1a 32-bit hash (no crypto needed for client-side dedup)
function fnv1a(str: string): string {
  let hash = 0x811c9dc5;
  for (let i = 0; i < str.length; i++) {
    hash ^= str.charCodeAt(i);
    hash = (hash * 0x01000193) >>> 0;
  }
  return hash.toString(16);
}

function getStored(): StoredReport[] {
  try {
    return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
  } catch {
    return [];
  }
}

function isLocalRateLimited() {
  const now = Date.now();
  return getStored().filter((r) => r.ts > now - 3_600_000).length >= MAX_PER_HOUR;
}

function isLocalDuplicate(slug: string, text: string) {
  const hash = fnv1a(slug + "|" + text.toLowerCase());
  return getStored().some((r) => r.hash === hash);
}

function recordLocalSubmission(slug: string, text: string) {
  const now = Date.now();
  const hash = fnv1a(slug + "|" + text.toLowerCase());
  const pruned = getStored().filter((r) => r.ts > now - 86_400_000);
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify([...pruned, { ts: now, hash }]));
  } catch {}
}

interface Props {
  metadata?: {
    permalink?: string;
  };
}

type Phase = "idle" | "selecting" | "confirming" | "submitting" | "done" | "error";

export default function TypoReport({ metadata }: Props): JSX.Element | null {
  const { siteConfig } = useDocusaurusContext();
  const slug = metadata?.permalink?.replace(/^\/|\/$/g, "") ?? "";
  const apiUrl = `${siteConfig.url}/api/typo.php`;

  // State machine: idle → selecting → confirming → submitting → done | error
  const [phase, setPhase] = useState<Phase>("idle");
  const [selectedText, setSelectedText] = useState("");
  const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 });
  const [feedbackType, setFeedbackType] = useState("");
  const [comment, setComment] = useState("");

  const nonceRef = useRef<string | null>(null);
  const articleRef = useRef<HTMLElement | null>(null);
  const wrapperRef = useRef<HTMLDivElement | null>(null);
  const contextRef = useRef(""); // ±100 chars around selection

  // Fetch nonce + wire up DOM listeners on mount.
  useEffect(() => {
    if (!slug) return;

    fetch(`${apiUrl}?nonce`)
      .then((r) => (r.ok ? r.json() : null))
      .then((data) => {
        if (data?.nonce) nonceRef.current = data.nonce;
      })
      .catch(() => {});

    const article =
      document.querySelector<HTMLElement>("article") ||
      document.querySelector<HTMLElement>(".theme-doc-markdown");
    if (!article) return;
    articleRef.current = article;

    // Arrow-assigned, not a `function` declaration: that lets TS carry the
    // `if (!article) return` narrowing above into this closure, so `article` is
    // non-null on lines below without a `!` assertion.
    const handleSelection = () => {
      const sel = window.getSelection();
      if (!sel || sel.isCollapsed) return;

      const text = sel.toString().trim();
      if (text.length < 3) return;

      const range = sel.getRangeAt(0);
      const rect = range.getBoundingClientRect();

      if (!article.contains(range.commonAncestorContainer)) return;

      const fullText = article.innerText || "";
      const idx = fullText.indexOf(text);
      contextRef.current =
        idx !== -1 ? fullText.slice(Math.max(0, idx - 100), idx + text.length + 100) : "";

      const top = window.scrollY + rect.bottom + 8;
      const left = Math.min(window.scrollX + rect.left, window.innerWidth - 296);

      setSelectedText(text);
      setTooltipPos({ top, left });
      setPhase("selecting");
    };

    function handleMouseDown(e: MouseEvent) {
      if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
        setPhase("idle");
      }
    }

    article.addEventListener("mouseup", handleSelection);
    article.addEventListener("touchend", handleSelection);
    document.addEventListener("mousedown", handleMouseDown);
    return () => {
      article.removeEventListener("mouseup", handleSelection);
      article.removeEventListener("touchend", handleSelection);
      document.removeEventListener("mousedown", handleMouseDown);
    };
  }, [slug, apiUrl]);

  const handleTypeSelect = useCallback(
    (type: string) => {
      if (isLocalRateLimited()) {
        setPhase("error");
        return;
      }
      if (isLocalDuplicate(slug, selectedText)) {
        setPhase("done");
        return;
      }
      setFeedbackType(type);
      setComment("");
      setPhase("confirming");
    },
    [slug, selectedText],
  );

  const handleSubmit = useCallback(
    async (e: FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      setPhase("submitting");

      let nonce = nonceRef.current;
      if (!nonce) {
        try {
          const r = await fetch(`${apiUrl}?nonce`);
          const d = r.ok ? await r.json() : null;
          nonce = d?.nonce ?? null;
          nonceRef.current = nonce;
        } catch {}
      }

      const honeypot =
        (e.currentTarget.elements.namedItem("website") as HTMLInputElement | null)
          ?.value ?? "";

      try {
        const res = await fetch(apiUrl, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            slug,
            text: selectedText,
            type: feedbackType,
            comment,
            context: contextRef.current,
            website: honeypot,
            nonce: nonce ?? "",
          }),
        });
        if (!res.ok) {
          setPhase("error");
          return;
        }
        recordLocalSubmission(slug, selectedText);
        setPhase("done");
      } catch {
        setPhase("error");
      }
    },
    [slug, selectedText, feedbackType, comment, apiUrl],
  );

  const handleBack = useCallback(() => setPhase("selecting"), []);
  const handleCancel = useCallback(() => setPhase("idle"), []);
  const handleDismiss = useCallback(() => setPhase("idle"), []);

  // SSR-safe: render nothing when idle.
  if (phase === "idle") return null;

  const typeInfo = FEEDBACK_TYPES.find((t) => t.id === feedbackType);

  return (
    <div
      ref={wrapperRef}
      className={styles.wrapper}
      style={{ top: tooltipPos.top, left: tooltipPos.left }}
    >
      {phase === "selecting" && (
        <div className={styles.typeCard}>
          <p className={styles.typePrompt}>What kind of issue?</p>
          <div className={styles.typeGrid}>
            {FEEDBACK_TYPES.map(({ id, icon, label }) => (
              <button
                key={id}
                className={styles.typeBtn}
                onClick={() => handleTypeSelect(id)}
              >
                <span className={styles.typeIcon}>{icon}</span>
                <span>{label}</span>
              </button>
            ))}
          </div>
          <button className={styles.cancelSmall} onClick={handleCancel}>
            Cancel
          </button>
        </div>
      )}

      {phase === "confirming" && (
        <form className={styles.form} onSubmit={handleSubmit}>
          <div className={styles.selectedPreview}>
            &quot;
            {selectedText.length > 80 ? selectedText.slice(0, 80) + "…" : selectedText}
            &quot;
          </div>
          {typeInfo && (
            <div className={styles.typeBadge}>
              {typeInfo.icon} {typeInfo.label}
              <button type="button" className={styles.changeTypeBtn} onClick={handleBack}>
                change
              </button>
            </div>
          )}
          <textarea
            className={styles.commentInput}
            placeholder="Optional details (max 300 chars)"
            maxLength={300}
            value={comment}
            onChange={(e) => setComment(e.target.value)}
          />
          {/* Honeypot — bots fill this, humans don't see it */}
          <input
            name="website"
            className={styles.honeypot}
            tabIndex={-1}
            autoComplete="off"
            defaultValue=""
          />
          <div className={styles.actions}>
            <button type="submit" className={styles.btnPrimary}>
              Send
            </button>
            <button type="button" className={styles.btnSecondary} onClick={handleCancel}>
              Cancel
            </button>
          </div>
          <p className={styles.disclaimer}>One-way signal — no reply will be sent.</p>
        </form>
      )}

      {phase === "submitting" && <div className={styles.status}>Sending…</div>}

      {phase === "done" && (
        <div className={styles.status}>
          Thanks for the feedback! ✓
          <button
            className={styles.dismissBtn}
            onClick={handleDismiss}
            aria-label="Dismiss"
          >
            ✕
          </button>
        </div>
      )}

      {phase === "error" && (
        <div className={styles.statusError}>
          Could not send.
          <button
            className={styles.dismissBtn}
            onClick={handleDismiss}
            aria-label="Dismiss"
          >
            ✕
          </button>
        </div>
      )}
    </div>
  );
}
```

Trois points à noter :

* `touchend` est enregistré en plus de `mouseup` pour que le widget fonctionne sur les appareils tactiles.
* `handleTypeSelect` exécute les vérifications de rate limit et de déduplication au moment du choix du type, pas à la sélection — cela évite de consommer un slot de rate limit quand l'utilisateur sélectionne simplement du texte pour le copier.
* Le paragraphe `disclaimer` (« Signal à sens unique — aucune réponse ne sera envoyée ») est volontaire : le dire explicitement évite la frustration.

Le module CSS utilise partout les propriétés personnalisées `--ifm-*` de Docusaurus, donc le widget suit votre thème et le mode sombre sans effort supplémentaire.

***

## Partie 3 — L'intégration dans Docusaurus {#part-3--wiring-into-docusaurus}

C'est ici qu'intervient le swizzling de Docusaurus. Plutôt que de modifier directement le fichier du thème en amont, Docusaurus vous permet de copier n'importe quel composant du thème dans `src/theme/` et de le surcharger. Si vous n'avez pas encore swizzlé `BlogPostItem`, lancez :

```bash
yarn swizzle @docusaurus/theme-classic BlogPostItem --wrap
```

Cela crée `src/theme/BlogPostItem/index.js`. Ouvrez-le et ajoutez :

**src/theme/BlogPostItem/index.js**

```javascript
// existing imports…
import TypoReport from "@site/src/components/TypoReport";

export default function BlogPostItem({ children, className }) {
  const { metadata, isBlogPostPage } = useBlogPost();
  // …

  return (
    <>
      <BlogPostItemContainer className={clsx(containerClassName, className)}>
        <BlogPostItemHeader />
        <BlogPostItemContent>{children}</BlogPostItemContent>
        <BlogPostItemFooter />

        {isBlogPostPage && (
          <TypoReport metadata={metadata} />
        )}
      </BlogPostItemContainer>
    </>
  );
}
```

Le garde `isBlogPostPage` est essentiel — sans lui, le composant se monterait aussi sur la page de liste du blog, où il n'y a pas d'élément article.

***

## Partie 4 — Le dashboard admin {#part-4--the-admin-dashboard}

L'endpoint admin PHP (`GET ?admin=TOKEN`) renvoie du JSON brut. Plutôt que de lire cette réponse brute, une petite page React sur `/typo-dashboard` la présente dans un format lisible : elle aplatit tous les tableaux de signalements par slug en une seule liste triée du plus récent au plus ancien, et affiche des compteurs récapitulatifs par type en haut.

Le dashboard est protégé par token : il réutilise la même clé `reactions_admin_token` dans `localStorage` que le dashboard des réactions, donc vous ne vous authentifiez qu'une fois pour les deux.

Si vous avez une page d'accueil admin, ajoutez cette entrée à son tableau `TOOLS` dans `src/pages/admin.js` :

**src/pages/admin.js**

```javascript
{
  icon: "🔤",
  title: "Feedback Reports",
  description: "Reader-flagged issues — typos, incorrect info, outdated content, suggestions.",
  href: "/typo-dashboard",
  accent: "#e07b00",
},
```

***

## Créer le composant {#create-the-component}

Créez ces fichiers dans votre site Docusaurus, puis copiez `api/.env.example` vers `api/.env` et collez-y les deux secrets générés plus haut :

**Project: /your\_docusaurus\_site**

**api/typo.php**

```php
<?php
declare(strict_types=1);

$_envVars = file_exists(__DIR__ . '/.env')
    ? (parse_ini_file(__DIR__ . '/.env') ?: [])
    : [];

define('ADMIN_EMAIL',            $_envVars['ADMIN_EMAIL']    ?? '');
define('ADMIN_TOKEN',            $_envVars['ADMIN_TOKEN']    ?? '');
define('IP_HASH_SALT',           $_envVars['IP_HASH_SALT']   ?? '');
define('NONCE_SECRET',           $_envVars['NONCE_SECRET']   ?? '');
define('SITE_URL',               'https://www.yourdomain.com');

define('NONCE_COOLDOWN_SECONDS', 900);
define('NOTIFY_COOLDOWN_SECONDS', 21600);
define('GLOBAL_WINDOW',   60);
define('GLOBAL_MAX',      20);
define('IP_HOURLY_MAX',   10);
define('IP_ARTICLE_MAX',  3);

// ── CORS ──────────────────────────────────────────────────────────────────────
$allowedOrigins = [SITE_URL, 'http://localhost:3000'];
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin !== '' && !in_array($origin, $allowedOrigins, true)) {
    http_response_code(403); exit;
}

header('Content-Type: application/json; charset=utf-8');
if ($origin !== '') {
    header("Access-Control-Allow-Origin: $origin");
    header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
    header('Access-Control-Allow-Headers: Content-Type');
    header('Vary: Origin');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }

// ── Shared helpers ────────────────────────────────────────────────────────────
function loadData(string $file): array {
    if (!file_exists($file)) return [];
    return json_decode(file_get_contents($file), true) ?: [];
}

function saveData(string $file, array $data): void {
    $fp = fopen($file, 'c+');
    if (!$fp) return;
    if (flock($fp, LOCK_EX)) {
        ftruncate($fp, 0); rewind($fp);
        fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
        flock($fp, LOCK_UN);
    }
    fclose($fp);
}

function sanitizeSlug(string $raw): string {
    return substr(preg_replace('/[^a-z0-9\-\/]/', '', strtolower(trim($raw))), 0, 200);
}

function jsonError(int $code, string $message): never {
    http_response_code($code);
    echo json_encode(['error' => $message]);
    exit;
}

// ── Nonce ─────────────────────────────────────────────────────────────────────
function makeNonce(int $window): string {
    return hash_hmac('sha256', $window . '|typo-report', NONCE_SECRET);
}

function validateNonce(string $candidate): bool {
    $now = (int) floor(time() / NONCE_COOLDOWN_SECONDS);
    return hash_equals(makeNonce($now), $candidate)
        || hash_equals(makeNonce($now - 1), $candidate);
}

// ── Input validation ──────────────────────────────────────────────────────────
function validateAndSanitize(array $body): array {
    if (($body['website'] ?? '') !== '') return []; // honeypot

    $slug = sanitizeSlug($body['slug'] ?? '');
    if ($slug === '') jsonError(400, 'Missing slug');

    $text = trim($body['text'] ?? '');
    if (mb_strlen($text) < 3 || mb_strlen($text) > 150
        || !preg_match('/^\P{C}+$/u', $text)) jsonError(400, 'Invalid text');

    $allowedTypes = ['typo', 'incorrect', 'outdated', 'suggestion'];
    $type = trim($body['type'] ?? '');
    if (!in_array($type, $allowedTypes, true)) jsonError(400, 'Invalid type');

    $comment = trim($body['comment'] ?? '');
    if ($comment !== '' && (mb_strlen($comment) > 300
        || !preg_match('/^\P{C}+$/u', $comment))) jsonError(400, 'Invalid comment');

    $context = mb_substr(trim($body['context'] ?? ''), 0, 300);

    return compact('slug', 'text', 'type', 'comment', 'context');
}

// ── Rate limiting ─────────────────────────────────────────────────────────────
function checkRateLimits(string $slug): void {
    $file  = __DIR__ . '/typo-ratelimit.json';
    $ip    = $_SERVER['REMOTE_ADDR'] ?? '';
    $ipKey = hash('sha256', IP_HASH_SALT . $ip);
    $now   = time();

    $fp = fopen($file, 'c+');
    if (!$fp || !flock($fp, LOCK_EX)) { if ($fp) fclose($fp); return; }

    $raw  = stream_get_contents($fp);
    $data = $raw !== '' ? (json_decode($raw, true) ?: []) : [];

    $g = $data['__global__'] ?? ['window_start' => $now, 'count' => 0];
    if ($now - $g['window_start'] > GLOBAL_WINDOW) $g = ['window_start' => $now, 'count' => 0];
    if (++$g['count'] > GLOBAL_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); }
    $data['__global__'] = $g;

    $ipData = $data[$ipKey] ?? ['hourly' => [], 'articles' => []];
    $ipData['hourly'] = array_values(array_filter($ipData['hourly'], fn($ts) => $ts > $now - 3600));
    if (count($ipData['hourly']) >= IP_HOURLY_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); }

    $artKey  = $ipKey . '|' . $slug;
    $artData = array_values(array_filter($data[$artKey] ?? [], fn($ts) => $ts > $now - 86400));
    if (count($artData) >= IP_ARTICLE_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); }

    $ipData['hourly'][] = $now;
    $artData[]          = $now;
    $data[$ipKey]       = $ipData;
    $data[$artKey]      = $artData;

    ftruncate($fp, 0); rewind($fp);
    fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
    flock($fp, LOCK_UN); fclose($fp);
}

// ── Dedup + store ─────────────────────────────────────────────────────────────
function isDuplicate(string $slug, string $text): bool {
    $hash = hash('sha256', $slug . '|' . mb_strtolower($text));
    foreach (loadData(__DIR__ . '/typo-data.json')[$slug] ?? [] as $r) {
        if (($r['text_hash'] ?? '') === $hash) return true;
    }
    return false;
}

function storeReport(array $fields): void {
    ['slug' => $slug, 'text' => $text, 'type' => $type,
     'comment' => $comment, 'context' => $context] = $fields;

    $ip = $_SERVER['REMOTE_ADDR'] ?? '';
    $fp = fopen(__DIR__ . '/typo-data.json', 'c+');
    if (!$fp) return;
    if (flock($fp, LOCK_EX)) {
        $raw  = stream_get_contents($fp);
        $data = $raw !== '' ? (json_decode($raw, true) ?: []) : [];
        $data[$slug][] = [
            'id'        => bin2hex(random_bytes(6)),
            'type'      => $type,
            'text'      => $text,
            'text_hash' => hash('sha256', $slug . '|' . mb_strtolower($text)),
            'context'   => $context,
            'comment'   => $comment,
            'ts'        => time(),
            'ip_hash'   => hash('sha256', IP_HASH_SALT . $ip),
        ];
        ftruncate($fp, 0); rewind($fp);
        fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
        flock($fp, LOCK_UN);
    }
    fclose($fp);
}

function maybeNotifyTypo(string $slug, string $text, string $type): void {
    $file     = __DIR__ . '/typo-notifications.json';
    $throttle = loadData($file);
    if (time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS) return;

    $typeLabels = [
        'typo'       => '🔤 Typo',
        'incorrect'  => '❌ Incorrect info',
        'outdated'   => '⏰ Outdated content',
        'suggestion' => '💡 Suggestion',
    ];
    $typeLabel  = $typeLabels[$type] ?? $type;
    $articleUrl = SITE_URL . '/' . $slug;
    $subject    = "[Blog] Feedback ($type) on: $slug";
    $body       = implode("\n", [
        "A reader flagged an issue.",
        "",
        "Article : $articleUrl",
        "Type    : $typeLabel",
        "Text    : $text",
        "",
        "Review all reports: GET " . SITE_URL . "/api/typo.php?admin=TOKEN",
    ]);
    $headers = "From: noreply@yourdomain.com\r\nContent-Type: text/plain; charset=utf-8";

    if (@mail(ADMIN_EMAIL, $subject, $body, $headers)) {
        $throttle[$slug] = time();
        saveData($file, $throttle);
    }
}

// ── Router ────────────────────────────────────────────────────────────────────
$method = $_SERVER['REQUEST_METHOD'];

if ($method === 'GET') {
    if (array_key_exists('nonce', $_GET)) {
        echo json_encode(['nonce' => makeNonce((int) floor(time() / NONCE_COOLDOWN_SECONDS))]);
        exit;
    }
    if (array_key_exists('admin', $_GET)) {
        if (ADMIN_TOKEN === '' || ($_GET['admin'] ?? '') !== ADMIN_TOKEN) jsonError(403, 'Forbidden');
        echo json_encode(loadData(__DIR__ . '/typo-data.json'));
        exit;
    }
    jsonError(400, 'Missing parameter');
}

if ($method !== 'POST') jsonError(405, 'Method not allowed');

$body  = json_decode(file_get_contents('php://input'), true) ?? [];
$nonce = $body['nonce'] ?? '';
if ($nonce === '' || !validateNonce($nonce)) jsonError(403, 'Invalid nonce');

$fields = validateAndSanitize($body);
if ($fields === []) { echo json_encode(['ok' => true]); exit; }

['slug' => $slug, 'text' => $text] = $fields;

checkRateLimits($slug);

if (isDuplicate($slug, $text)) { echo json_encode(['ok' => true]); exit; }

storeReport($fields);
maybeNotifyTypo($slug, $text, $fields['type']);

echo json_encode(['ok' => true]);
```

**api/.htaccess**

```
Options -Indexes

<FilesMatch "^(typo-data\.json|typo-ratelimit\.json|typo-notifications\.json)$">
    Require all denied
</FilesMatch>
```

**api/.env.example**

```example
ADMIN_EMAIL=your_email@example.com
ADMIN_TOKEN=change-me-to-a-long-random-string
IP_HASH_SALT=change-me-to-another-long-random-string
NONCE_SECRET=change-me-to-yet-another-long-random-string
```

**src/components/TypoReport/index.tsx**

```typescript
import {
  useState,
  useEffect,
  useRef,
  useCallback,
  type FormEvent,
  type JSX,
} from "react";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import styles from "./styles.module.css";

const STORAGE_KEY = "typo_reports"; // [{ts, hash}]
const MAX_PER_HOUR = 5;

const FEEDBACK_TYPES = [
  { id: "typo", icon: "🔤", label: "Typo" },
  { id: "incorrect", icon: "❌", label: "Incorrect" },
  { id: "outdated", icon: "⏰", label: "Outdated" },
  { id: "suggestion", icon: "💡", label: "Suggestion" },
];

interface StoredReport {
  ts: number;
  hash: string;
}

// FNV-1a 32-bit hash (no crypto needed for client-side dedup)
function fnv1a(str: string): string {
  let hash = 0x811c9dc5;
  for (let i = 0; i < str.length; i++) {
    hash ^= str.charCodeAt(i);
    hash = (hash * 0x01000193) >>> 0;
  }
  return hash.toString(16);
}

function getStored(): StoredReport[] {
  try {
    return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
  } catch {
    return [];
  }
}

function isLocalRateLimited() {
  const now = Date.now();
  return getStored().filter((r) => r.ts > now - 3_600_000).length >= MAX_PER_HOUR;
}

function isLocalDuplicate(slug: string, text: string) {
  const hash = fnv1a(slug + "|" + text.toLowerCase());
  return getStored().some((r) => r.hash === hash);
}

function recordLocalSubmission(slug: string, text: string) {
  const now = Date.now();
  const hash = fnv1a(slug + "|" + text.toLowerCase());
  const pruned = getStored().filter((r) => r.ts > now - 86_400_000);
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify([...pruned, { ts: now, hash }]));
  } catch {}
}

interface Props {
  metadata?: {
    permalink?: string;
  };
}

type Phase = "idle" | "selecting" | "confirming" | "submitting" | "done" | "error";

export default function TypoReport({ metadata }: Props): JSX.Element | null {
  const { siteConfig } = useDocusaurusContext();
  const slug = metadata?.permalink?.replace(/^\/|\/$/g, "") ?? "";
  const apiUrl = `${siteConfig.url}/api/typo.php`;

  // State machine: idle → selecting → confirming → submitting → done | error
  const [phase, setPhase] = useState<Phase>("idle");
  const [selectedText, setSelectedText] = useState("");
  const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 });
  const [feedbackType, setFeedbackType] = useState("");
  const [comment, setComment] = useState("");

  const nonceRef = useRef<string | null>(null);
  const articleRef = useRef<HTMLElement | null>(null);
  const wrapperRef = useRef<HTMLDivElement | null>(null);
  const contextRef = useRef(""); // ±100 chars around selection

  // Fetch nonce + wire up DOM listeners on mount.
  useEffect(() => {
    if (!slug) return;

    fetch(`${apiUrl}?nonce`)
      .then((r) => (r.ok ? r.json() : null))
      .then((data) => {
        if (data?.nonce) nonceRef.current = data.nonce;
      })
      .catch(() => {});

    const article =
      document.querySelector<HTMLElement>("article") ||
      document.querySelector<HTMLElement>(".theme-doc-markdown");
    if (!article) return;
    articleRef.current = article;

    // Arrow-assigned, not a `function` declaration: that lets TS carry the
    // `if (!article) return` narrowing above into this closure, so `article` is
    // non-null on lines below without a `!` assertion.
    const handleSelection = () => {
      const sel = window.getSelection();
      if (!sel || sel.isCollapsed) return;

      const text = sel.toString().trim();
      if (text.length < 3) return;

      const range = sel.getRangeAt(0);
      const rect = range.getBoundingClientRect();

      if (!article.contains(range.commonAncestorContainer)) return;

      const fullText = article.innerText || "";
      const idx = fullText.indexOf(text);
      contextRef.current =
        idx !== -1 ? fullText.slice(Math.max(0, idx - 100), idx + text.length + 100) : "";

      const top = window.scrollY + rect.bottom + 8;
      const left = Math.min(window.scrollX + rect.left, window.innerWidth - 296);

      setSelectedText(text);
      setTooltipPos({ top, left });
      setPhase("selecting");
    };

    function handleMouseDown(e: MouseEvent) {
      if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
        setPhase("idle");
      }
    }

    article.addEventListener("mouseup", handleSelection);
    article.addEventListener("touchend", handleSelection);
    document.addEventListener("mousedown", handleMouseDown);
    return () => {
      article.removeEventListener("mouseup", handleSelection);
      article.removeEventListener("touchend", handleSelection);
      document.removeEventListener("mousedown", handleMouseDown);
    };
  }, [slug, apiUrl]);

  const handleTypeSelect = useCallback(
    (type: string) => {
      if (isLocalRateLimited()) {
        setPhase("error");
        return;
      }
      if (isLocalDuplicate(slug, selectedText)) {
        setPhase("done");
        return;
      }
      setFeedbackType(type);
      setComment("");
      setPhase("confirming");
    },
    [slug, selectedText],
  );

  const handleSubmit = useCallback(
    async (e: FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      setPhase("submitting");

      let nonce = nonceRef.current;
      if (!nonce) {
        try {
          const r = await fetch(`${apiUrl}?nonce`);
          const d = r.ok ? await r.json() : null;
          nonce = d?.nonce ?? null;
          nonceRef.current = nonce;
        } catch {}
      }

      const honeypot =
        (e.currentTarget.elements.namedItem("website") as HTMLInputElement | null)
          ?.value ?? "";

      try {
        const res = await fetch(apiUrl, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            slug,
            text: selectedText,
            type: feedbackType,
            comment,
            context: contextRef.current,
            website: honeypot,
            nonce: nonce ?? "",
          }),
        });
        if (!res.ok) {
          setPhase("error");
          return;
        }
        recordLocalSubmission(slug, selectedText);
        setPhase("done");
      } catch {
        setPhase("error");
      }
    },
    [slug, selectedText, feedbackType, comment, apiUrl],
  );

  const handleBack = useCallback(() => setPhase("selecting"), []);
  const handleCancel = useCallback(() => setPhase("idle"), []);
  const handleDismiss = useCallback(() => setPhase("idle"), []);

  // SSR-safe: render nothing when idle.
  if (phase === "idle") return null;

  const typeInfo = FEEDBACK_TYPES.find((t) => t.id === feedbackType);

  return (
    <div
      ref={wrapperRef}
      className={styles.wrapper}
      style={{ top: tooltipPos.top, left: tooltipPos.left }}
    >
      {phase === "selecting" && (
        <div className={styles.typeCard}>
          <p className={styles.typePrompt}>What kind of issue?</p>
          <div className={styles.typeGrid}>
            {FEEDBACK_TYPES.map(({ id, icon, label }) => (
              <button
                key={id}
                className={styles.typeBtn}
                onClick={() => handleTypeSelect(id)}
              >
                <span className={styles.typeIcon}>{icon}</span>
                <span>{label}</span>
              </button>
            ))}
          </div>
          <button className={styles.cancelSmall} onClick={handleCancel}>
            Cancel
          </button>
        </div>
      )}

      {phase === "confirming" && (
        <form className={styles.form} onSubmit={handleSubmit}>
          <div className={styles.selectedPreview}>
            &quot;
            {selectedText.length > 80 ? selectedText.slice(0, 80) + "…" : selectedText}
            &quot;
          </div>
          {typeInfo && (
            <div className={styles.typeBadge}>
              {typeInfo.icon} {typeInfo.label}
              <button type="button" className={styles.changeTypeBtn} onClick={handleBack}>
                change
              </button>
            </div>
          )}
          <textarea
            className={styles.commentInput}
            placeholder="Optional details (max 300 chars)"
            maxLength={300}
            value={comment}
            onChange={(e) => setComment(e.target.value)}
          />
          {/* Honeypot — bots fill this, humans don't see it */}
          <input
            name="website"
            className={styles.honeypot}
            tabIndex={-1}
            autoComplete="off"
            defaultValue=""
          />
          <div className={styles.actions}>
            <button type="submit" className={styles.btnPrimary}>
              Send
            </button>
            <button type="button" className={styles.btnSecondary} onClick={handleCancel}>
              Cancel
            </button>
          </div>
          <p className={styles.disclaimer}>One-way signal — no reply will be sent.</p>
        </form>
      )}

      {phase === "submitting" && <div className={styles.status}>Sending…</div>}

      {phase === "done" && (
        <div className={styles.status}>
          Thanks for the feedback! ✓
          <button
            className={styles.dismissBtn}
            onClick={handleDismiss}
            aria-label="Dismiss"
          >
            ✕
          </button>
        </div>
      )}

      {phase === "error" && (
        <div className={styles.statusError}>
          Could not send.
          <button
            className={styles.dismissBtn}
            onClick={handleDismiss}
            aria-label="Dismiss"
          >
            ✕
          </button>
        </div>
      )}
    </div>
  );
}
```

**src/components/TypoReport/styles.module.css**

```css
.wrapper {
  position: absolute;
  z-index: 9999;
  max-width: 280px;
}

/* ── Type selection card ──────────────────────────────────────────────── */
.typeCard {
  width: 250px;
  padding: 10px 12px 8px;
  background: var(--ifm-background-surface-color);
  border: 1px solid var(--ifm-color-emphasis-300);
  border-radius: 8px;
  box-shadow: 0 4px 16px rgb(0, 0, 0, 0.15);
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.typePrompt {
  margin: 0;
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--ifm-color-emphasis-700);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.typeGrid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 6px;
}

.typeBtnWide {
  grid-column: 1 / -1;
}

.typeBtn {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 8px;
  background: var(--ifm-color-emphasis-100);
  border: 1px solid var(--ifm-color-emphasis-200);
  border-radius: 6px;
  font-size: 0.8rem;
  font-weight: 500;
  cursor: pointer;
  color: var(--ifm-font-color-base);
  transition:
    background 0.12s,
    border-color 0.12s;
  text-align: left;
}

.typeBtn:hover {
  background: var(--ifm-color-primary-lightest);
  border-color: var(--ifm-color-primary);
  color: var(--ifm-color-primary-dark);
}

.typeIcon {
  font-size: 1rem;
  line-height: 1;
}

.cancelSmall {
  align-self: center;
  background: transparent;
  border: none;
  font-size: 0.75rem;
  color: var(--ifm-color-emphasis-500);
  cursor: pointer;
  padding: 0;
}

.cancelSmall:hover {
  color: var(--ifm-color-emphasis-800);
}

/* ── Form card ────────────────────────────────────────────────────────── */
.form {
  width: 280px;
  padding: 12px;
  background: var(--ifm-background-surface-color);
  border: 1px solid var(--ifm-color-emphasis-300);
  border-radius: 8px;
  box-shadow: 0 4px 16px rgb(0, 0, 0, 0.15);
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.selectedPreview {
  font-style: italic;
  font-size: 0.82rem;
  color: var(--ifm-color-emphasis-700);
  border-left: 3px solid var(--ifm-color-primary);
  padding-left: 8px;
  background: var(--ifm-color-emphasis-100);
  border-radius: 0 4px 4px 0;
  overflow-wrap: break-word;
}

.typeBadge {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--ifm-color-primary-dark);
  background: var(--ifm-color-primary-lightest);
  border: 1px solid var(--ifm-color-primary-light);
  border-radius: 4px;
  padding: 3px 8px;
}

.changeTypeBtn {
  margin-left: auto;
  background: transparent;
  border: none;
  font-size: 0.72rem;
  font-weight: 400;
  color: var(--ifm-color-emphasis-600);
  cursor: pointer;
  text-decoration: underline;
  padding: 0;
}

.changeTypeBtn:hover {
  color: var(--ifm-color-emphasis-900);
}

.commentInput {
  width: 100%;
  min-height: 56px;
  resize: vertical;
  padding: 6px 8px;
  font-size: 0.82rem;
  border: 1px solid var(--ifm-color-emphasis-300);
  border-radius: 4px;
  background: var(--ifm-background-color);
  color: var(--ifm-font-color-base);
  font-family: inherit;
  box-sizing: border-box;
}

.commentInput:focus {
  outline: none;
  border-color: var(--ifm-color-primary);
}

.honeypot {
  position: absolute;
  left: -9999px;
  opacity: 0;
  pointer-events: none;
}

.actions {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
}

.btnPrimary {
  padding: 5px 14px;
  background: var(--ifm-color-primary);
  color: white;
  border: none;
  border-radius: 5px;
  font-size: 0.82rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s;
}

.btnPrimary:hover {
  background: var(--ifm-color-primary-dark);
}

.btnSecondary {
  padding: 5px 14px;
  background: transparent;
  color: var(--ifm-color-emphasis-700);
  border: 1px solid var(--ifm-color-emphasis-400);
  border-radius: 5px;
  font-size: 0.82rem;
  cursor: pointer;
  transition: border-color 0.15s;
}

.btnSecondary:hover {
  border-color: var(--ifm-color-emphasis-600);
}

.disclaimer {
  margin: 0;
  font-size: 0.72rem;
  color: var(--ifm-color-emphasis-500);
  text-align: center;
}

/* ── Status badges ────────────────────────────────────────────────────── */
.status,
.statusError {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 12px;
  border-radius: 6px;
  font-size: 0.82rem;
  font-weight: 600;
  box-shadow: 0 2px 8px rgb(0, 0, 0, 0.15);
}

.status {
  background: var(--ifm-color-success-contrast-background);
  color: var(--ifm-color-success-dark);
}

.statusError {
  background: var(--ifm-color-danger-contrast-background);
  color: var(--ifm-color-danger-dark);
}

.dismissBtn {
  margin-left: auto;
  background: transparent;
  border: none;
  cursor: pointer;
  font-size: 0.75rem;
  color: inherit;
  opacity: 0.7;
  padding: 0 2px;
  line-height: 1;
}

.dismissBtn:hover {
  opacity: 1;
}

/* ── Dark mode overrides ──────────────────────────────────────────────── */
[data-theme="dark"] .typeCard,
[data-theme="dark"] .form {
  box-shadow: 0 4px 16px rgb(0, 0, 0, 0.4);
}

[data-theme="dark"] .typeBtn {
  background: var(--ifm-color-emphasis-200);
  border-color: var(--ifm-color-emphasis-300);
}

[data-theme="dark"] .typeBtn:hover {
  background: var(--ifm-color-emphasis-300);
  border-color: var(--ifm-color-primary);
}

[data-theme="dark"] .selectedPreview {
  background: var(--ifm-color-emphasis-200);
}

[data-theme="dark"] .typeBadge {
  background: rgb(var(--ifm-color-primary-rgb, 0 100 200) / 0.15);
}

/*
 * Custom dark-mode-only shades, tuned for contrast that the plain Infima
 * success/danger tokens above don't quite give at this size/weight. Legitimate
 * color-no-hex exception, see AGENTS.md and
 * .todos/039-hardcoded-hex-colors-no-token-system.md.
 */
/* stylelint-disable color-no-hex */
[data-theme="dark"] .status {
  background: #1a3d2a;
  color: #6fcf97;
}

[data-theme="dark"] .statusError {
  background: #3d1a1a;
  color: #eb5757;
}
/* stylelint-enable color-no-hex */
```

**src/pages/typo-dashboard.js**

```javascript
import { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import Layout from "@theme/Layout";
import Head from "@docusaurus/Head";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import styles from "./typo-dashboard.module.css";

const STORAGE_KEY = "reactions_admin_token"; // reuse same token as reactions

const TYPE_LABELS = {
  typo: { icon: "🔤", label: "Typo" },
  incorrect: { icon: "❌", label: "Incorrect" },
  outdated: { icon: "⏰", label: "Outdated" },
  suggestion: { icon: "💡", label: "Suggestion" },
  translation: { icon: "🌐", label: "Translation" },
};

// ── Sub-components ───────────────────────────────────────────────────────────

function TypeBadge({ type }) {
  const info = TYPE_LABELS[type] ?? { icon: "❓", label: type ?? "unknown" };
  return (
    <span className={`${styles.typeBadge} ${styles[`type_${type}`] ?? ""}`}>
      {info.icon} {info.label}
    </span>
  );
}

TypeBadge.propTypes = {
  type: PropTypes.string,
};

function ReportCard({ report, slug, siteUrl, onResolve, onDelete }) {
  const date = new Date(report.ts * 1000).toLocaleDateString("en-GB", {
    day: "2-digit",
    month: "short",
    year: "numeric",
  });

  return (
    <div className={`${styles.reportCard} ${report.resolved ? styles.resolved : ""}`}>
      <div className={styles.reportHeader}>
        <TypeBadge type={report.type} />
        <span className={styles.reportDate}>{date}</span>
        <a
          href={`${siteUrl}/${slug}`}
          target="_blank"
          rel="noopener noreferrer"
          className={styles.slugLink}
        >
          {slug} ↗
        </a>
      </div>
      <blockquote className={styles.reportText}>&quot;{report.text}&quot;</blockquote>
      {report.context && <p className={styles.reportContext}>…{report.context}…</p>}
      {report.comment && (
        <p className={styles.reportComment}>
          <strong>Note:</strong> {report.comment}
        </p>
      )}
      <div className={styles.reportActions}>
        <button
          className={`${styles.actionBtn} ${report.resolved ? styles.btnUnresolve : styles.btnResolve}`}
          onClick={() => onResolve(report.id)}
        >
          {report.resolved ? "↩ Reopen" : "✓ Done"}
        </button>
        <button
          className={`${styles.actionBtn} ${styles.btnDelete}`}
          onClick={() => {
            if (window.confirm("Delete this report permanently?")) onDelete(report.id);
          }}
        >
          Delete
        </button>
      </div>
    </div>
  );
}

ReportCard.propTypes = {
  report: PropTypes.shape({
    id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
    ts: PropTypes.number.isRequired,
    type: PropTypes.string,
    text: PropTypes.string.isRequired,
    context: PropTypes.string,
    comment: PropTypes.string,
    resolved: PropTypes.bool,
  }).isRequired,
  slug: PropTypes.string.isRequired,
  siteUrl: PropTypes.string.isRequired,
  onResolve: PropTypes.func.isRequired,
  onDelete: PropTypes.func.isRequired,
};

function AuthForm({ onSubmit }) {
  const [token, setToken] = useState("");

  return (
    <div className={styles.authWrap}>
      <p>Enter your admin token to view the typo reports.</p>
      <form
        className={styles.authForm}
        onSubmit={(e) => {
          e.preventDefault();
          onSubmit(token.trim());
        }}
      >
        <input
          className={styles.authInput}
          type="password"
          placeholder="Admin token"
          value={token}
          onChange={(e) => setToken(e.target.value)}
          autoFocus
        />
        <button className={styles.authBtn} type="submit">
          View
        </button>
      </form>
    </div>
  );
}

AuthForm.propTypes = {
  onSubmit: PropTypes.func.isRequired,
};

// ── Page ─────────────────────────────────────────────────────────────────────

export default function TypoDashboard() {
  const { siteConfig } = useDocusaurusContext();
  const apiUrl = `${siteConfig.url}/api/typo.php`;

  const [token, setToken] = useState("");
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);
  const [hideResolved, setHideResolved] = useState(false);

  // Priority: URL hash → localStorage → empty (show form).
  // Must run in useEffect — localStorage is unavailable during SSR,
  // and React does not re-run useState initializers on client hydration.
  useEffect(() => {
    const initial =
      window.location.hash.slice(1) || localStorage.getItem(STORAGE_KEY) || "";
    // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional SSR/hydration-safe read, see comment above
    if (initial) setToken(initial);
  }, []);

  const fetchData = useCallback(
    async (tk) => {
      setLoading(true);
      setError(null);
      try {
        const res = await fetch(`${apiUrl}?admin=${encodeURIComponent(tk)}`);
        if (res.status === 403) {
          setError("Invalid token.");
          setLoading(false);
          return;
        }
        if (!res.ok) {
          setError("API error.");
          setLoading(false);
          return;
        }
        const json = await res.json();
        setData(json);
        setToken(tk);
        try {
          localStorage.setItem(STORAGE_KEY, tk);
        } catch {}
      } catch {
        setError("Network error.");
      } finally {
        setLoading(false);
      }
    },
    [apiUrl],
  );

  // Reactive sync: refetch whenever the token changes (login, restore from storage, hash link).
  useEffect(() => {
    // eslint-disable-next-line react-hooks/set-state-in-effect -- fetchData sets state, but this is a legitimate reaction to `token` changing, not derivable at render time
    if (token) fetchData(token);
  }, [token, fetchData]);

  const resolveReport = useCallback(
    async (id) => {
      try {
        await fetch(`${apiUrl}?admin=${encodeURIComponent(token)}`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ action: "resolve", id }),
        });
        setData((prev) => {
          const next = {};
          for (const [slug, reports] of Object.entries(prev)) {
            next[slug] = reports.map((r) =>
              r.id === id ? { ...r, resolved: !(r.resolved ?? false) } : r,
            );
          }
          return next;
        });
      } catch {}
    },
    [apiUrl, token],
  );

  const deleteReport = useCallback(
    async (id) => {
      try {
        await fetch(`${apiUrl}?admin=${encodeURIComponent(token)}`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ action: "delete", id }),
        });
        setData((prev) => {
          const next = {};
          for (const [slug, reports] of Object.entries(prev)) {
            const filtered = reports.filter((r) => r.id !== id);
            if (filtered.length > 0) next[slug] = filtered;
          }
          return next;
        });
      } catch {}
    },
    [apiUrl, token],
  );

  const allReports = data
    ? Object.entries(data)
        .flatMap(([slug, reports]) => reports.map((r) => ({ ...r, slug })))
        .sort((a, b) => b.ts - a.ts)
    : [];

  const resolvedCount = allReports.filter((r) => r.resolved).length;
  const visibleReports = hideResolved
    ? allReports.filter((r) => !r.resolved)
    : allReports;

  const countByType = allReports.reduce((acc, r) => {
    acc[r.type] = (acc[r.type] ?? 0) + 1;
    return acc;
  }, {});

  return (
    <Layout title="Typo Reports" noFooter>
      <Head>
        <meta name="robots" content="noindex, nofollow" />
      </Head>

      <div className={styles.page}>
        <div className={styles.header}>
          <h1 className={styles.title}>Feedback Reports</h1>
          <a href="/admin" className={styles.backLink}>
            ← Admin
          </a>
        </div>

        {!data && !loading && <AuthForm onSubmit={fetchData} />}
        {loading && <p className={styles.dim}>Loading…</p>}
        {error && <p className={styles.err}>{error}</p>}

        {data && (
          <>
            <div className={styles.summary}>
              <div className={styles.summaryCard}>
                <div className={styles.summaryValue}>{allReports.length}</div>
                <div className={styles.summaryLabel}>Total reports</div>
              </div>
              {Object.entries(TYPE_LABELS).map(([id, { icon, label }]) => (
                <div key={id} className={styles.summaryCard}>
                  <div className={styles.summaryValue}>{countByType[id] ?? 0}</div>
                  <div className={styles.summaryLabel}>
                    {icon} {label}
                  </div>
                </div>
              ))}
            </div>

            {resolvedCount > 0 && (
              <button
                className={styles.filterToggle}
                onClick={() => setHideResolved((h) => !h)}
              >
                {hideResolved
                  ? `Show resolved (${resolvedCount})`
                  : `Hide resolved (${resolvedCount})`}
              </button>
            )}

            {visibleReports.length === 0 ? (
              <p className={styles.dim}>No reports yet.</p>
            ) : (
              <div className={styles.reportList}>
                {visibleReports.map((r) => (
                  <ReportCard
                    key={r.id}
                    report={r}
                    slug={r.slug}
                    siteUrl={siteConfig.url}
                    onResolve={resolveReport}
                    onDelete={deleteReport}
                  />
                ))}
              </div>
            )}
          </>
        )}
      </div>
    </Layout>
  );
}
```

**src/pages/typo-dashboard.module.css**

```css
.page {
  max-width: 860px;
  margin: 0 auto;
  padding: 2rem 1.5rem 4rem;
}

.header {
  display: flex;
  align-items: baseline;
  gap: 1.5rem;
  margin-bottom: 1.5rem;
}

.title {
  margin: 0;
  font-size: 1.6rem;
}

.backLink {
  font-size: 0.85rem;
  color: var(--ifm-color-emphasis-600);
  text-decoration: none;
}

.backLink:hover {
  color: var(--ifm-color-primary);
}

/* ── Auth form ── */
.authWrap {
  max-width: 380px;
}

.authForm {
  display: flex;
  gap: 8px;
  margin-top: 0.75rem;
}

.authInput {
  flex: 1;
  padding: 6px 10px;
  border: 1px solid var(--ifm-color-emphasis-300);
  border-radius: 5px;
  font-size: 0.9rem;
  background: var(--ifm-background-color);
  color: var(--ifm-font-color-base);
}

.authInput:focus {
  outline: none;
  border-color: var(--ifm-color-primary);
}

.authBtn {
  padding: 6px 18px;
  background: var(--ifm-color-primary);
  color: white;
  border: none;
  border-radius: 5px;
  font-size: 0.9rem;
  font-weight: 600;
  cursor: pointer;
}

/* ── Summary ── */
.summary {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
  margin-bottom: 1.5rem;
}

.summaryCard {
  flex: 1;
  min-width: 100px;
  padding: 12px 16px;
  background: var(--ifm-background-surface-color);
  border: 1px solid var(--ifm-color-emphasis-200);
  border-radius: 8px;
  text-align: center;
}

.summaryValue {
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--ifm-color-primary);
}

.summaryLabel {
  font-size: 0.78rem;
  color: var(--ifm-color-emphasis-600);
  margin-top: 2px;
}

/* ── Report cards ── */
.reportList {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.reportCard {
  padding: 14px 16px;
  background: var(--ifm-background-surface-color);
  border: 1px solid var(--ifm-color-emphasis-200);
  border-radius: 8px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.reportHeader {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}

.reportDate {
  font-size: 0.78rem;
  color: var(--ifm-color-emphasis-500);
}

.slugLink {
  font-size: 0.78rem;
  color: var(--ifm-color-emphasis-600);
  text-decoration: none;
  margin-left: auto;
}

.slugLink:hover {
  color: var(--ifm-color-primary);
}

.reportText {
  margin: 0;
  font-style: italic;
  font-size: 0.9rem;
  border-left: 3px solid var(--ifm-color-primary);
  padding-left: 10px;
  color: var(--ifm-color-emphasis-800);
}

.reportContext {
  margin: 0;
  font-size: 0.78rem;
  color: var(--ifm-color-emphasis-500);
  background: var(--ifm-color-emphasis-100);
  border-radius: 4px;
  padding: 4px 8px;
  white-space: pre-wrap;
  overflow-wrap: break-word;
}

.reportComment {
  margin: 0;
  font-size: 0.82rem;
  color: var(--ifm-color-emphasis-700);
}

/* ── Type badge ── */
.typeBadge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 0.75rem;
  font-weight: 600;
  background: var(--ifm-color-emphasis-200);
  color: var(--ifm-color-emphasis-800);
}

/*
 * ── Report-type status colors ──
 * Hand-tuned palette (blue/red/amber/green) for report categories, kept
 * distinct from Infima's own info/danger/warning/success shades. Legitimate
 * color-no-hex exception, see AGENTS.md and
 * .todos/039-hardcoded-hex-colors-no-token-system.md.
 */
/* stylelint-disable color-no-hex */
.type_typo {
  background: #e8f4ff;
  color: #0066cc;
}
.type_incorrect {
  background: #fde8e8;
  color: #c0392b;
}
.type_outdated {
  background: #fff8e1;
  color: #b56a00;
}
.type_suggestion {
  background: #e8f5e9;
  color: #1a7f37;
}

/* ── Resolved state ── */
.resolved {
  opacity: 0.5;
}

.resolved .reportText {
  text-decoration: line-through;
  border-left-color: var(--ifm-color-emphasis-400);
}

/* ── Report actions ── */
.reportActions {
  display: flex;
  gap: 8px;
  margin-top: 4px;
}

.actionBtn {
  padding: 3px 10px;
  border: none;
  border-radius: 4px;
  font-size: 0.75rem;
  font-weight: 600;
  cursor: pointer;
  transition: opacity 0.15s;
}

.actionBtn:hover {
  opacity: 0.75;
}

.btnResolve {
  background: #e8f5e9;
  color: #1a7f37;
}
.btnUnresolve {
  background: var(--ifm-color-emphasis-200);
  color: var(--ifm-color-emphasis-700);
}
.btnDelete {
  background: #fde8e8;
  color: #c0392b;
}
/* stylelint-enable color-no-hex */

/* ── Filter toggle ── */
.filterToggle {
  display: inline-block;
  margin-bottom: 1rem;
  padding: 4px 12px;
  background: none;
  border: 1px solid var(--ifm-color-emphasis-300);
  border-radius: 4px;
  font-size: 0.82rem;
  color: var(--ifm-color-emphasis-600);
  cursor: pointer;
}

.filterToggle:hover {
  border-color: var(--ifm-color-primary);
  color: var(--ifm-color-primary);
}

/* ── States ── */
.dim {
  color: var(--ifm-color-emphasis-500);
}
.err {
  color: var(--ifm-color-danger-dark);
  font-weight: 600;
}

/* ── Dark mode ── */
/* stylelint-disable color-no-hex -- report-type status palette, see note above */
[data-theme="dark"] .type_typo {
  background: #0a2540;
  color: #60b0ff;
}
[data-theme="dark"] .type_incorrect {
  background: #3d1a1a;
  color: #eb5757;
}
[data-theme="dark"] .type_outdated {
  background: #3d2e00;
  color: #f0b429;
}
[data-theme="dark"] .type_suggestion {
  background: #1a3d2a;
  color: #6fcf97;
}
[data-theme="dark"] .btnResolve {
  background: #1a3d2a;
  color: #6fcf97;
}
[data-theme="dark"] .btnDelete {
  background: #3d1a1a;
  color: #eb5757;
}
/* stylelint-enable color-no-hex */
```

Montez ensuite le composant dans `src/theme/BlogPostItem/index.js` comme montré en partie 3.

### Déploiement {#deployment}

L'API utilise `declare(strict_types=1)` et un type de retour `never` sur `jsonError()`, donc votre serveur doit tourner en **PHP 8.1 ou plus récent** :

```bash
php -v
```

Les trois fichiers JSON doivent être accessibles en écriture par le process du serveur web :

```bash
$ chmod 664 api/typo-data.json api/typo-ratelimit.json api/typo-notifications.json
```

Une fois déployé, vérifiez l'aller-retour complet avant d'annoncer la fonctionnalité :

**smoke-test.sh**

```bash
# 1. Fetch a nonce
NONCE=$(curl -s "https://yourdomain.com/api/typo.php?nonce" | jq -r .nonce)

# 2. Submit a test report
curl -s -X POST "https://yourdomain.com/api/typo.php" \
  -H "Content-Type: application/json" \
  -d "{\"slug\":\"test/smoke\",\"text\":\"hello world\",\"type\":\"typo\",\"comment\":\"\",\"context\":\"\",\"website\":\"\",\"nonce\":\"$NONCE\"}"
# → {"ok":true}

# 3. Confirm the admin endpoint
curl -s "https://yourdomain.com/api/typo.php?admin=YOUR_TOKEN" | jq .
# → {"test/smoke": [{"type":"typo",...}]}

# 4. Confirm .htaccess is protecting the file
curl -I "https://yourdomain.com/api/typo-data.json"
# → HTTP/1.1 403 Forbidden

# 5. Confirm POST without a nonce is rejected
curl -s -X POST "https://yourdomain.com/api/typo.php" \
  -H "Content-Type: application/json" \
  -d '{"slug":"test","text":"hello world","type":"typo","comment":"","context":"","website":""}'
# → {"error":"Invalid nonce"}

# 6. Confirm an invalid type is rejected
curl -s -X POST "https://yourdomain.com/api/typo.php" \
  -H "Content-Type: application/json" \
  -d "{\"slug\":\"test\",\"text\":\"hello world\",\"type\":\"question\",\"comment\":\"\",\"context\":\"\",\"website\":\"\",\"nonce\":\"$NONCE\"}"
# → {"error":"Invalid type"}
```

Nettoyez après le test :

**cleanup.sh**

```bash
echo '{}' > api/typo-data.json
echo '{}' > api/typo-ratelimit.json
echo '{}' > api/typo-notifications.json
```

***

## Conception de la sécurité — couche par couche (sautez si vous voulez juste que ça tourne) {#security-design--layer-by-layer-skip-this-if-you-just-want-it-running}

Chaque `POST` traverse neuf couches indépendantes avant qu'un signalement ne soit écrit.

### Couche 1 — CORS {#layer-1--cors}

Seuls `https://www.yourdomain.com` et `http://localhost:3000` (dev) sont autorisés comme `Origin`. Toute autre origine reçoit un `403` silencieux. Les requêtes same-origin n'envoient pas d'en-tête `Origin` et passent.

### Couche 2 — Whitelist des méthodes {#layer-2--method-whitelist}

L'API ne répond qu'à `GET` (nonce + admin), `POST` (envoi) et `OPTIONS` (preflight). Tout le reste reçoit `405 Method Not Allowed`.

### Couche 3 — Nonce HMAC {#layer-3--hmac-nonce}

Sans cela, quiconque connaît l'URL de votre API peut envoyer des signalements arbitraires avec une simple commande `curl`. Le nonce est un token HMAC basé sur le temps :

```php
$nonce = hash_hmac('sha256', floor(time() / 900) . '|typo-report', NONCE_SECRET);
```

Le token est valable pour la fenêtre de 15 minutes en cours et la précédente (pour éviter une expiration gênante quand un utilisateur récupère le nonce à la minute 14 et envoie à la minute 15). Il n'y a aucun stockage — le token s'auto-valide. Chaque `POST` doit inclure cette valeur dans son body.

### Couche 4 — Validation des entrées, whitelist de types et honeypot {#layer-4--input-validation-type-whitelist-and-honeypot}

Le honeypot est un `<input name="website">` caché, invisible pour les humains mais rempli par les bots. Si `website !== ""`, l'API renvoie `200 OK` sans rien stocker.

Les vraies entrées sont ensuite validées :

* `slug` — alphanumérique, tirets, slashes, 200 caractères max
* `text` — 3 à 150 caractères, Unicode imprimable uniquement (`\P{C}` — pas de caractères de contrôle)
* `type` — doit valoir `typo`, `incorrect`, `outdated` ou `suggestion` (toute autre valeur → 400)
* `comment` — facultatif, 300 caractères max, même filtre de caractères
* `context` — facultatif, 300 caractères max, trimmé

### Couche 5 — Rate limit global {#layer-5--global-rate-limit}

Une fenêtre glissante de 60 secondes avec un maximum de 20 requêtes toutes IP confondues. Cela protège des pics soudains. L'état de la fenêtre est stocké sous une clé `__global__` dans `typo-ratelimit.json`, mis à jour dans un unique bloc `LOCK_EX`.

### Couche 6 — Rate limits par IP {#layer-6--per-ip-rate-limits}

Deux limites suivies indépendamment :

* 10 signalements max par IP et par heure glissante
* 3 signalements max par IP, par article, par 24 heures

Les adresses IP ne sont jamais stockées en clair. Elles sont hashées avec un sel secret : `hash('sha256', IP_HASH_SALT . $ip)`. À sens unique — vous ne pouvez pas retrouver l'IP d'origine — mais suffisamment cohérent pour agréger.

Le script lit `REMOTE_ADDR`, jamais `X-Forwarded-For` : sur un hébergement mutualisé, `X-Forwarded-For` peut être falsifié par le client, ce qui permettrait à une seule IP d'en usurper des milliers et de contourner les limites par IP.

### Couche 7 — Déduplication {#layer-7--deduplication}

Un hash SHA-256 de `slug + "|" + lowercase(text)` est stocké dans `text_hash` sur chaque signalement. Avant l'écriture, les signalements existants pour ce slug sont parcourus. Si le hash existe déjà, l'API renvoie `200 OK` sans rien stocker. Cela absorbe silencieusement les envois en double.

### Couche 8 — Écritures sous verrou de fichier {#layer-8--file-locked-writes}

PHP sur hébergement mutualisé est multi-process. Chaque écriture utilise `fopen` + `flock(LOCK_EX)` + `ftruncate` + `rewind` + `fwrite` + `flock(LOCK_UN)` + `fclose`. La vérification du rate limit et l'écriture se font dans le même verrou pour éviter les races TOCTOU.

### Couche 9 — Throttle des emails {#layer-9--email-throttle}

Vous ne voulez pas d'une boîte mail inondée si un article populaire récolte 50 signalements en une heure. Le système de notification consulte `typo-notifications.json` pour connaître le timestamp du dernier email par article. Un nouvel email n'est envoyé que si plus de 6 heures se sont écoulées depuis le précédent.

***

## Limitations connues {#known-limitations}

### Débordement du tooltip sur le bord gauche {#tooltip-overflow-on-the-left-edge}

Le clamp `Math.min(left, window.innerWidth - 296)` empêche le formulaire de 280px de sortir de l'écran à droite. Un débordement à gauche (sélection près du bord gauche sur des viewports très étroits) n'est pas géré.

### NAT en hébergement mutualisé {#shared-hosting-nat}

Les utilisateurs derrière le même NAT (par exemple le Wi-Fi d'un bureau) partagent la même `REMOTE_ADDR`. Si trois personnes différentes du même bureau signalent des problèmes sur le même article en 24 heures, la quatrième sera bloquée par le rate limit. C'est un compromis acceptable vu la contrainte de l'hébergement mutualisé.

***

## Conclusion {#conclusion}

Les lecteurs n'ouvriront jamais une issue GitHub pour vous dire qu'une commande est obsolète — mais ils sélectionneront une phrase et cliqueront sur un bouton. Avec environ 250 lignes de PHP et 300 lignes de React, chacun de ces signaux arrive désormais dans votre boîte mail, avec la phrase exacte et son contexte, sans base de données ni service tiers.

L'architecture est volontairement simple : chaque morceau peut être compris, débogué et modifié en lisant un seul fichier. Si vous voulez aussi que vos lecteurs vous disent ce qu'ils ont *aimé*, le [composant Reaction](/blog/docusaurus-reactions) utilise exactement le même schéma PHP + JSON.
