# Médiathèque de photos réutilisables — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ajouter une médiathèque (réserve de photos importées) dans un panneau à droite de l'écran d'édition d'une page, pour réutiliser une photo sur plusieurs emplacements/pages.

**Architecture:** Nouvelle table `t_media_library` + `MediaLibraryRepository`. Le `MediaController` gagne l'import, le renommage, la suppression (gardée) et l'affectation depuis la bibliothèque (`assignFromLibrary`) ; les uploads d'emplacement existants sont aussi enregistrés dans la médiathèque. La vue `page.php` passe en deux colonnes avec un partial panneau + un petit JS d'armement « Choisir → Utiliser ici ».

**Tech Stack:** PHP 8.2+ strict, PDO MySQL utf8mb4, templates PHP natifs, GD/WebP (P7), JS vanilla.

**Spec :** `docs/superpowers/specs/2026-06-16-mediatheque-design.md`

**Conventions (CLAUDE.md) :** `declare(strict_types=1)` ; PSR-12 4 espaces ; `e()` sur toute sortie ; PDO préparé avec vérif `substr_count($sql,'?') === count($params)` ; CSRF global (`CsrfMiddleware`) → juste `csrf_field()` dans les formulaires ; jamais inventer une colonne ; migrations idempotentes.

**Commandes :** PHP = `C:\wamp64\bin\php\php8.4.0\php.exe`. Tests : `"$PHP" vendor/bin/phpunit`. Analyse : `"$PHP" vendor/bin/phpstan analyse src --level=6`. Lint fichier : `"$PHP" -l <file>`.

**État existant (rappel) :** `MediaController` possède `index`, `page`, `saveSingle`, `collectionAdd`, `collectionDelete`, `collectionReorder`, `revert`, `entityImage`, et les privés `registry()`, `slotDef()`, `isSafeImageUrl()`, `purgeSlotFiles()`. `MediaOverrideRepository` a `upsertSingle()`, `addToCollection()`. `ImageUploadService::upload(array,'pages',slug)` + `delete()`. Helpers globaux : `e()`, `csrf_field()`, `flash()`, `redirect()`, `view()`, `base_path()`.

---

## File Structure

**Créés :**
- `database/migrations/034_create_media_library.sql` — table `t_media_library`.
- `src/Repositories/MediaLibraryRepository.php` — CRUD médiathèque + garde de référence.
- `views/partials/admin/_media_library_panel.php` — panneau de droite (import + grille + gérer).

**Modifiés :**
- `src/Controllers/Admin/MediaController.php` — `assignFromLibrary`, `libraryImport`, `libraryRename`, `libraryDelete`, helpers privés `registerUpload`/`imageDims`/`normalizeFiles`, hooks dans `saveSingle`/`collectionAdd`, `library` passé à `page()`.
- `config/routes.php` — 4 routes médiathèque.
- `views/pages/admin/medias/page.php` — réorganisation 2 colonnes + boutons « Choisir » + inclusion panneau + JS.

---

## Task 1: Migration — table `t_media_library`

**Files:**
- Create: `database/migrations/034_create_media_library.sql`

- [ ] **Step 1: Écrire la migration (idempotente)**

```sql
-- ALDANA migration 034
-- Médiathèque : réserve de photos importées, réutilisables sur plusieurs emplacements.
-- image_url est UNIQUE (dédoublonnage) ; label = nom convivial optionnel.
-- Indépendante de t_media_overrides : une ligne ici ne place rien par elle-même.

CREATE TABLE IF NOT EXISTS t_media_library (
    library_ID  BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    image_url   VARCHAR(255)    NOT NULL,
    label       VARCHAR(255)    NULL,
    width       SMALLINT UNSIGNED NULL,
    height      SMALLINT UNSIGNED NULL,
    created_at  TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (library_ID),
    UNIQUE KEY uq_library_url (image_url)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

- [ ] **Step 2: Lint / vérif visuelle** — parenthèses, point-virgule final, `IF NOT EXISTS`.
- [ ] **Step 3: Note** — l'application BDD est manuelle (le harnais n'a pas de BDD de test). Ne pas tenter de connexion. Signaler l'application manuelle comme concern.
- [ ] **Step 4: Commit**

```bash
git add database/migrations/034_create_media_library.sql
git commit -m "feat(medias): migration 034 table t_media_library"
```

---

## Task 2: `MediaLibraryRepository`

**Files:**
- Create: `src/Repositories/MediaLibraryRepository.php`

- [ ] **Step 1: Implémenter** (suivre le style de `src/Repositories/MediaOverrideRepository.php`)

```php
<?php
declare(strict_types=1);

namespace App\Repositories;

use App\Helpers\Database;
use PDO;

final class MediaLibraryRepository
{
    private PDO $pdo;

    public function __construct()
    {
        $this->pdo = Database::getInstance()->getConnection();
    }

    /** @return list<array<string,mixed>> Toutes les photos, la plus récente d'abord. */
    public function listAll(): array
    {
        return $this->pdo
            ->query('SELECT * FROM t_media_library ORDER BY created_at DESC, library_ID DESC')
            ->fetchAll();
    }

    /** @return array<string,mixed>|null */
    public function findByID(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM t_media_library WHERE library_ID = ?');
        $stmt->execute([$id]);
        $row = $stmt->fetch();
        return $row ?: null;
    }

    public function create(string $url, ?string $label, ?int $width, ?int $height): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO t_media_library (image_url, label, width, height) VALUES (?, ?, ?, ?)'
        );
        $stmt->execute([$url, $label, $width, $height]);
        return (int) $this->pdo->lastInsertId();
    }

    /** Enregistre une URL si absente (no-op si déjà présente). */
    public function registerIfAbsent(string $url, ?string $label, ?int $width, ?int $height): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO t_media_library (image_url, label, width, height)
             VALUES (?, ?, ?, ?)
             ON DUPLICATE KEY UPDATE library_ID = library_ID'
        );
        $stmt->execute([$url, $label, $width, $height]);
    }

    public function rename(int $id, ?string $label): void
    {
        $this->pdo->prepare('UPDATE t_media_library SET label = ? WHERE library_ID = ?')
                  ->execute([$label, $id]);
    }

    public function delete(int $id): void
    {
        $this->pdo->prepare('DELETE FROM t_media_library WHERE library_ID = ?')->execute([$id]);
    }

    /** Nombre d'emplacements (t_media_overrides) utilisant cette URL. */
    public function isReferenced(string $url): int
    {
        $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM t_media_overrides WHERE image_url = ?');
        $stmt->execute([$url]);
        return (int) $stmt->fetchColumn();
    }
}
```

- [ ] **Step 2: Vérif** — `"$PHP" -l src/Repositories/MediaLibraryRepository.php` ; `"$PHP" vendor/bin/phpstan analyse src --level=6` → aucune **nouvelle** erreur sur ce fichier (les annotations `array` non typées préexistantes ailleurs ne sont pas de votre ressort). Vérifier que chaque requête a autant de `?` que de params.
- [ ] **Step 3: Commit**

```bash
git add src/Repositories/MediaLibraryRepository.php
git commit -m "feat(medias): MediaLibraryRepository (CRUD médiathèque + garde référence)"
```

---

## Task 3: `MediaController` — affectation, import, renommage, suppression + hooks

**Files:**
- Modify: `src/Controllers/Admin/MediaController.php`

- [ ] **Step 1: Ajouter l'import du repository**

En tête de classe, à côté des `use App\Repositories\MediaOverrideRepository;` existants, ajouter :
```php
use App\Repositories\MediaLibraryRepository;
```

- [ ] **Step 2: Charger la médiathèque dans `page()` (tolérant si table absente)**

Dans `page()`, juste avant l'appel `echo view('layouts.admin', [...])`, ajouter :
```php
        $library = [];
        try {
            $library = (new MediaLibraryRepository())->listAll();
        } catch (\Throwable $e) {
            $library = []; // table 034 non appliquée → panneau vide, pas de fatale
        }
```
Puis, dans le tableau passé à `view('pages.admin.medias.page', [...])`, ajouter la clé :
```php
                'library'   => $library,
```

- [ ] **Step 3: Ajouter les helpers privés** (à la fin de la classe, près de `purgeSlotFiles`)

```php
    /** Dimensions [w,h] d'une image locale uploadée, ou [null,null]. */
    private function imageDims(string $webUrl): array
    {
        if (!str_starts_with($webUrl, '/uploads/')) {
            return [null, null];
        }
        $info = @getimagesize(base_path('public' . $webUrl));
        return $info !== false ? [(int) $info[0], (int) $info[1]] : [null, null];
    }

    /** Enregistre une URL uploadée dans la médiathèque. Tolérant si table absente. */
    private function registerUpload(string $url): void
    {
        try {
            [$w, $h] = $this->imageDims($url);
            (new MediaLibraryRepository())->registerIfAbsent($url, null, $w, $h);
        } catch (\Throwable $e) {
            // médiathèque indisponible → l'upload d'emplacement reste valide
        }
    }

    /**
     * Normalise $_FILES['image_file'] en liste de fichiers individuels,
     * que l'input soit simple (image_file) ou multiple (image_file[]).
     *
     * @param mixed $f
     * @return list<array<string,mixed>>
     */
    private function normalizeFiles(mixed $f): array
    {
        if (!is_array($f) || !isset($f['error'])) {
            return [];
        }
        if (is_array($f['error'])) {
            $out = [];
            foreach ($f['error'] as $i => $err) {
                if ((int) $err === UPLOAD_ERR_NO_FILE) {
                    continue;
                }
                $out[] = [
                    'name'     => $f['name'][$i]     ?? '',
                    'type'     => $f['type'][$i]     ?? '',
                    'tmp_name' => $f['tmp_name'][$i] ?? '',
                    'error'    => $err,
                    'size'     => $f['size'][$i]     ?? 0,
                ];
            }
            return $out;
        }
        return (int) $f['error'] === UPLOAD_ERR_NO_FILE ? [] : [$f];
    }

    /** URL de retour sûre vers une page médias (clé du registre) ou l'index. */
    private function mediasReturn(): string
    {
        $key = (string) ($_POST['return_key'] ?? '');
        return $key !== '' && $this->slotDefPageExists($key)
            ? '/admin/settings/medias/page?key=' . urlencode($key)
            : '/admin/settings/medias';
    }

    /** Vrai si $pageKey est une page connue du registre. */
    private function slotDefPageExists(string $pageKey): bool
    {
        return isset($this->registry()[$pageKey]);
    }
```

- [ ] **Step 4: Ajouter `assignFromLibrary()`**

```php
    public function assignFromLibrary(): void
    {
        $slotKey = (string) ($_POST['slot_key'] ?? '');
        $libID   = (int) ($_POST['library_ID'] ?? 0);
        $def     = $this->slotDef($slotKey);
        if ($def === null) {
            flash('error', 'Emplacement inconnu.');
            redirect('/admin/settings/medias');
        }
        $lib = (new MediaLibraryRepository())->findByID($libID);
        if ($lib === null) {
            flash('error', 'Photo introuvable dans la médiathèque.');
            redirect('/admin/settings/medias/page?key=' . urlencode($def['_pageKey']));
        }
        $url = (string) $lib['image_url'];
        $alt = ($lib['label'] ?? '') !== '' ? (string) $lib['label'] : null;

        $repo = new MediaOverrideRepository();
        if ($def['type'] === 'single') {
            $repo->upsertSingle($slotKey, $url, $alt);
        } else {
            $repo->addToCollection($slotKey, $url, $alt);
        }
        flash('success', 'Photo placée depuis la médiathèque.');
        redirect('/admin/settings/medias/page?key=' . urlencode($def['_pageKey']));
    }
```

- [ ] **Step 5: Ajouter `libraryImport()`**

```php
    public function libraryImport(): void
    {
        $repo     = new MediaLibraryRepository();
        $uploader = new ImageUploadService();
        $files    = $this->normalizeFiles($_FILES['image_file'] ?? null);

        if ($files === []) {
            flash('error', 'Aucun fichier à importer.');
            redirect($this->mediasReturn());
        }

        $ok = 0;
        foreach ($files as $f) {
            try {
                $url = $uploader->upload($f, 'pages', 'library');
                [$w, $h] = $this->imageDims($url);
                $repo->create($url, null, $w, $h);
                $ok++;
            } catch (\RuntimeException $e) {
                flash('error', 'Image : ' . $e->getMessage());
            }
        }
        if ($ok > 0) {
            flash('success', $ok . ' photo(s) importée(s) dans la médiathèque.');
        }
        redirect($this->mediasReturn());
    }
```

- [ ] **Step 6: Ajouter `libraryRename()` et `libraryDelete()`**

```php
    public function libraryRename(): void
    {
        $id    = (int) ($_POST['library_ID'] ?? 0);
        $label = trim((string) ($_POST['label'] ?? '')) ?: null;
        if ($id > 0) {
            (new MediaLibraryRepository())->rename($id, $label);
            flash('success', 'Nom mis à jour.');
        }
        redirect($this->mediasReturn());
    }

    public function libraryDelete(): void
    {
        $id   = (int) ($_POST['library_ID'] ?? 0);
        $repo = new MediaLibraryRepository();
        $lib  = $id > 0 ? $repo->findByID($id) : null;
        if ($lib === null) {
            flash('error', 'Photo introuvable.');
            redirect($this->mediasReturn());
        }
        $url = (string) $lib['image_url'];
        $refs = $repo->isReferenced($url);
        if ($refs > 0) {
            flash('error', "Suppression impossible : photo utilisée sur {$refs} emplacement(s).");
            redirect($this->mediasReturn());
        }
        $repo->delete($id);
        (new ImageUploadService())->delete($url);
        flash('success', 'Photo retirée de la médiathèque.');
        redirect($this->mediasReturn());
    }
```

- [ ] **Step 7: Hook d'enregistrement dans `saveSingle()`**

Dans `saveSingle()`, le bloc d'upload de fichier est :
```php
                $newUrl = $uploader->upload($_FILES['image_file'], 'pages', str_replace('.', '-', $slotKey));
                if ($current !== null && $current !== $newUrl) {
                    $uploader->delete($current);
                }
```
Ajouter juste après (avant le `}` qui ferme ce `if`) :
```php
                $this->registerUpload($newUrl);
```

- [ ] **Step 8: Hook d'enregistrement dans `collectionAdd()`**

Dans `collectionAdd()`, le bloc d'upload de fichier est :
```php
                $url = $uploader->upload($_FILES['image_file'], 'pages', str_replace('.', '-', $slotKey));
```
Ajouter juste après cette ligne :
```php
                $this->registerUpload($url);
```

- [ ] **Step 9: Vérif**

Run: `"$PHP" -l src/Controllers/Admin/MediaController.php`
Run: `"$PHP" vendor/bin/phpstan analyse src --level=6` → corriger uniquement les **nouvelles** erreurs sur MediaController.php (ex. annoter un `@return array{...}` si phpstan exige un type sur `imageDims`). Expected final: pas d'erreur sur MediaController.php.

- [ ] **Step 10: Commit**

```bash
git add src/Controllers/Admin/MediaController.php
git commit -m "feat(medias): assignFromLibrary + import/renommage/suppression médiathèque + hooks"
```

---

## Task 4: Routes

**Files:**
- Modify: `config/routes.php`

- [ ] **Step 1: Ajouter les routes** après la dernière route `…/medias/…` existante (clés à SIMPLE espace, comme les voisines) :

```php
    'POST /admin/settings/medias/assign'         => ['App\Controllers\Admin\MediaController', 'assignFromLibrary', ['middleware' => 'admin']],
    'POST /admin/settings/medias/library/import' => ['App\Controllers\Admin\MediaController', 'libraryImport',     ['middleware' => 'admin']],
    'POST /admin/settings/medias/library/rename' => ['App\Controllers\Admin\MediaController', 'libraryRename',     ['middleware' => 'admin']],
    'POST /admin/settings/medias/library/delete' => ['App\Controllers\Admin\MediaController', 'libraryDelete',     ['middleware' => 'admin']],
```

- [ ] **Step 2: Vérif** — `"$PHP" -l config/routes.php` ; `grep -n "medias/assign\|medias/library" config/routes.php` montre 4 lignes, méthode `POST ` + espace unique.
- [ ] **Step 3: Commit**

```bash
git add config/routes.php
git commit -m "feat(medias): routes médiathèque (assign + import/rename/delete)"
```

---

## Task 5: Vue — panneau médiathèque + réorg 2 colonnes + JS

**Files:**
- Create: `views/partials/admin/_media_library_panel.php`
- Modify: `views/pages/admin/medias/page.php`

- [ ] **Step 1: Créer le partial `views/partials/admin/_media_library_panel.php`**

```php
<?php
declare(strict_types=1);
/**
 * Panneau médiathèque (colonne de droite de l'écran d'édition d'une page).
 *
 * @var list<array<string,mixed>> $library   photos de la médiathèque
 * @var string                    $pageKey   clé de la page courante (retour)
 */
$library = $library ?? [];
$pageKey = $pageKey ?? '';
?>
<aside class="media-lib" id="media-lib">
    <h2 style="font-size:1.1rem;margin:.25rem 0 .75rem;">Médiathèque</h2>

    <p class="media-lib-target text-muted" id="media-lib-target" hidden>
        Placer dans : <strong id="media-lib-target-label"></strong>
    </p>
    <p class="media-lib-hint text-muted" id="media-lib-hint">
        Cliquez « Choisir » sur un emplacement, puis « Utiliser ici » sous une photo.
    </p>

    <form method="post" action="/admin/settings/medias/library/import"
          enctype="multipart/form-data" style="margin-bottom:1rem;">
        <?= csrf_field() ?>
        <input type="hidden" name="return_key" value="<?= e($pageKey) ?>">
        <label style="display:block;font-size:.9rem;">Importer dans le catalogue
            <input type="file" name="image_file[]" multiple
                   accept="image/jpeg,image/png,image/webp,image/gif"></label>
        <button type="submit" class="btn btn-primary btn-sm" style="margin-top:.4rem;">Importer</button>
    </form>

    <?php if ($library === []): ?>
        <p class="text-muted">Médiathèque vide. Importez des photos ci-dessus.</p>
    <?php endif; ?>

    <div class="media-lib-grid">
        <?php foreach ($library as $img): ?>
            <div class="media-lib-item">
                <img src="<?= e((string) $img['image_url']) ?>" alt=""
                     style="width:100%;height:80px;object-fit:cover;border:1px solid #ddd;border-radius:4px;">
                <?php if (!empty($img['width']) && !empty($img['height'])): ?>
                    <small class="text-muted"><?= (int) $img['width'] ?>×<?= (int) $img['height'] ?></small>
                <?php endif; ?>

                <form method="post" action="/admin/settings/medias/library/rename"
                      style="display:flex;gap:.25rem;margin:.25rem 0;">
                    <?= csrf_field() ?>
                    <input type="hidden" name="return_key" value="<?= e($pageKey) ?>">
                    <input type="hidden" name="library_ID" value="<?= (int) $img['library_ID'] ?>">
                    <input type="text" name="label" value="<?= e((string) ($img['label'] ?? '')) ?>"
                           placeholder="Nom…" style="flex:1;min-width:0;font-size:.8rem;">
                    <button type="submit" class="btn btn-secondary btn-sm" title="Renommer">✓</button>
                </form>

                <div style="display:flex;gap:.25rem;">
                    <form method="post" action="/admin/settings/medias/assign"
                          class="media-lib-use" style="flex:1;">
                        <?= csrf_field() ?>
                        <input type="hidden" name="slot_key" value="">
                        <input type="hidden" name="library_ID" value="<?= (int) $img['library_ID'] ?>">
                        <button type="submit" class="btn btn-primary btn-sm" style="width:100%;">Utiliser ici</button>
                    </form>
                    <form method="post" action="/admin/settings/medias/library/delete"
                          onsubmit="return confirm('Supprimer cette photo de la médiathèque ?');">
                        <?= csrf_field() ?>
                        <input type="hidden" name="return_key" value="<?= e($pageKey) ?>">
                        <input type="hidden" name="library_ID" value="<?= (int) $img['library_ID'] ?>">
                        <button type="submit" class="btn btn-danger btn-sm" title="Supprimer">🗑</button>
                    </form>
                </div>
            </div>
        <?php endforeach; ?>
    </div>
</aside>
```

- [ ] **Step 2: Réécrire `views/pages/admin/medias/page.php`** (deux colonnes + bouton « Choisir » par emplacement + inclusion panneau + JS). Contenu complet :

```php
<?php
declare(strict_types=1);
/**
 * @var string $pageKey
 * @var array<string,mixed> $page
 * @var array<string, list<array<string,mixed>>> $overrides  indexé par slot_key
 * @var list<array<string,mixed>> $library
 */
$maxMb   = \App\Services\ImageUploadService::MAX_BYTES >> 20;
$library = $library ?? [];
?>
<style>
.media-edit { display:flex; gap:2rem; align-items:flex-start; }
.media-edit-main { flex:1 1 auto; min-width:0; }
.media-lib { flex:0 0 320px; position:sticky; top:1rem; max-height:calc(100vh - 2rem);
    overflow:auto; border-left:1px solid #e2e2e2; padding-left:1rem; }
.media-lib-grid { display:grid; grid-template-columns:1fr 1fr; gap:.75rem; }
.media-lib-item { font-size:.8rem; }
body.lib-armed .media-lib-target { display:block; }
@media (max-width:900px){ .media-edit{ flex-direction:column; } .media-lib{ flex-basis:auto;
    position:static; border-left:0; padding-left:0; } }
</style>

<p style="display:flex;gap:1.25rem;align-items:center;flex-wrap:wrap;">
    <a href="/admin/settings/medias">← Toutes les pages</a>
    <?php if (!empty($page['route'])): ?>
        <a href="<?= e((string) $page['route']) ?>" target="_blank" rel="noopener">
            Prévisualiser la page ↗
        </a>
    <?php endif; ?>
</p>
<h1 style="font-size:1.6rem;margin:.25rem 0 1.25rem;">Médias — <?= e($page['label'] ?? $pageKey) ?></h1>

<div class="media-edit">
    <div class="media-edit-main">
    <?php foreach ($page['slots'] as $slotName => $slot): ?>
        <?php
        $slotKey   = $pageKey . '.' . $slotName;
        $rows      = $overrides[$slotKey] ?? [];
        $slotLabel = (string) ($slot['label'] ?? $slotName);
        ?>
        <fieldset class="form-group" style="margin-bottom:2rem;">
            <legend><strong><?= e($slotLabel) ?></strong>
                <?php if (!empty($slot['recommended'])): ?>
                    <small class="text-muted">(conseillé : <?= e($slot['recommended']) ?>)</small>
                <?php endif; ?>
            </legend>

            <button type="button" class="btn btn-secondary btn-sm media-choose"
                    data-choose-slot="<?= e($slotKey) ?>" data-choose-label="<?= e($slotLabel) ?>"
                    style="margin-bottom:.75rem;">Choisir dans le catalogue →</button>

            <?php if ($slot['type'] === 'single'): ?>
                <?php $cur = $rows[0] ?? null; ?>
                <form method="post" action="/admin/settings/medias/single" enctype="multipart/form-data">
                    <?= csrf_field() ?>
                    <input type="hidden" name="slot_key" value="<?= e($slotKey) ?>">

                    <?php if ($cur !== null): ?>
                        <img src="<?= e($cur['image_url']) ?>" alt="Aperçu"
                             style="max-width:240px;max-height:160px;border:1px solid #ddd;border-radius:4px;display:block;margin-bottom:.5rem;">
                    <?php else: ?>
                        <p class="text-muted">Image par défaut :
                            <code><?= e((string) ($slot['default'] ?? '')) ?></code></p>
                    <?php endif; ?>

                    <label>Nouvelle image
                        <input type="file" name="image_file"
                               accept="image/jpeg,image/png,image/webp,image/gif"></label>
                    <small class="text-muted">JPG/PNG/WebP/GIF — max <?= (int) $maxMb ?> Mo, converti en WebP.</small>

                    <label>Ou URL externe
                        <input type="url" name="image_url" placeholder="https://..."></label>

                    <label>Texte alternatif (accessibilité / SEO)
                        <input type="text" name="alt_text"
                               value="<?= e((string) ($cur['alt_text'] ?? ($slot['default_alt'] ?? ''))) ?>"></label>

                    <div style="margin-top:.5rem;">
                        <button type="submit" class="btn btn-primary">Enregistrer</button>
                        <?php if ($cur !== null): ?>
                            <label style="margin-left:1rem;">
                                <input type="checkbox" name="image_delete" value="1">
                                Revenir à l'image d'origine
                            </label>
                        <?php endif; ?>
                    </div>
                </form>

            <?php else: /* collection */ ?>
                <?php if ($rows !== []): ?>
                    <form method="post" action="/admin/settings/medias/collection/order">
                        <?= csrf_field() ?>
                        <input type="hidden" name="slot_key" value="<?= e($slotKey) ?>">
                        <ul style="list-style:none;padding:0;">
                            <?php foreach ($rows as $row): ?>
                                <li style="display:flex;align-items:center;gap:.75rem;margin-bottom:.5rem;">
                                    <input type="hidden" name="order[]" value="<?= (int) $row['override_ID'] ?>">
                                    <img src="<?= e($row['image_url']) ?>" alt=""
                                         style="width:96px;height:64px;object-fit:cover;border:1px solid #ddd;border-radius:4px;">
                                    <input type="number" name="pos_display" value="<?= (int) $row['position'] ?>"
                                           style="width:4rem;" disabled title="Position (réordonnez avec les flèches)">
                                    <span class="text-muted"><?= e((string) ($row['alt_text'] ?? '')) ?></span>
                                </li>
                            <?php endforeach; ?>
                        </ul>
                        <button type="submit" class="btn btn-secondary">Enregistrer l'ordre</button>
                        <small class="text-muted">L'ordre suit l'ordre des lignes ci-dessus.</small>
                    </form>

                    <ul style="list-style:none;padding:0;margin-top:.75rem;">
                        <?php foreach ($rows as $row): ?>
                            <li style="display:inline-block;margin:0 .5rem .5rem 0;">
                                <form method="post" action="/admin/settings/medias/collection/del"
                                      onsubmit="return confirm('Retirer cette image ?');" style="display:inline;">
                                    <?= csrf_field() ?>
                                    <input type="hidden" name="slot_key" value="<?= e($slotKey) ?>">
                                    <input type="hidden" name="override_ID" value="<?= (int) $row['override_ID'] ?>">
                                    <button type="submit" class="btn btn-danger btn-sm">Retirer #<?= (int) $row['override_ID'] ?></button>
                                </form>
                            </li>
                        <?php endforeach; ?>
                    </ul>
                <?php else: ?>
                    <p class="text-muted">Aucune image personnalisée — la galerie affiche les
                        <?= (int) count($slot['defaults'] ?? []) ?> images par défaut.</p>
                <?php endif; ?>

                <form method="post" action="/admin/settings/medias/collection/add" enctype="multipart/form-data"
                      style="margin-top:1rem;">
                    <?= csrf_field() ?>
                    <input type="hidden" name="slot_key" value="<?= e($slotKey) ?>">
                    <label>Ajouter une image
                        <input type="file" name="image_file"
                               accept="image/jpeg,image/png,image/webp,image/gif"></label>
                    <label>Ou URL externe
                        <input type="url" name="image_url" placeholder="https://..."></label>
                    <label>Texte alternatif
                        <input type="text" name="alt_text"></label>
                    <button type="submit" class="btn btn-primary">Ajouter</button>
                </form>

                <?php if ($rows !== []): ?>
                    <form method="post" action="/admin/settings/medias/revert"
                          onsubmit="return confirm('Revenir aux images d\'origine ? Les images importées seront supprimées.');"
                          style="margin-top:.75rem;">
                        <?= csrf_field() ?>
                        <input type="hidden" name="slot_key" value="<?= e($slotKey) ?>">
                        <button type="submit" class="btn btn-secondary">Revenir aux images d'origine</button>
                    </form>
                <?php endif; ?>
            <?php endif; ?>
        </fieldset>
    <?php endforeach; ?>
    </div>

    <?= view('partials.admin._media_library_panel', ['library' => $library, 'pageKey' => $pageKey]) ?>
</div>

<script>
(function () {
    var activeSlot = null;
    var targetBox  = document.getElementById('media-lib-target');
    var targetLbl  = document.getElementById('media-lib-target-label');

    document.querySelectorAll('.media-choose').forEach(function (btn) {
        btn.addEventListener('click', function () {
            activeSlot = btn.getAttribute('data-choose-slot');
            if (targetLbl) { targetLbl.textContent = btn.getAttribute('data-choose-label') || ''; }
            if (targetBox) { targetBox.hidden = false; }
            document.body.classList.add('lib-armed');
            var lib = document.getElementById('media-lib');
            if (lib && lib.scrollIntoView) { lib.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
        });
    });

    document.querySelectorAll('.media-lib-use').forEach(function (form) {
        form.addEventListener('submit', function (e) {
            if (!activeSlot) {
                e.preventDefault();
                alert('Cliquez d’abord « Choisir dans le catalogue » sur un emplacement.');
                return;
            }
            form.querySelector('input[name="slot_key"]').value = activeSlot;
        });
    });
})();
</script>
```

- [ ] **Step 3: Vérif**

Run: `"$PHP" -l views/partials/admin/_media_library_panel.php` et `"$PHP" -l views/pages/admin/medias/page.php`
Compter `csrf_field()` dans le partial : 3 formulaires par item (rename, assign, delete) + 1 import → présent dans chacun.

- [ ] **Step 4: Recette manuelle** (migration 034 appliquée)

1. Ouvrir `/admin/settings/medias/page?key=public.home` → panneau médiathèque visible à droite.
2. Importer 1–2 photos → apparaissent dans la grille.
3. Sur un emplacement single, cliquer « Choisir dans le catalogue », puis « Utiliser ici » sur une vignette → l'image se place ; « Prévisualiser la page ↗ » la montre.
4. Sur une galerie, « Choisir » puis « Utiliser ici » → ajout à la collection.
5. Renommer une photo → le nom devient l'alt par défaut à la prochaine affectation.
6. Supprimer une photo non utilisée → disparaît ; tenter de supprimer une photo utilisée → refus avec compte.
7. Uploader une image directement dans un emplacement → elle apparaît aussi dans la médiathèque (sans doublon).

Expected: conforme aux critères §6 de la spec.

- [ ] **Step 5: Commit**

```bash
git add views/partials/admin/_media_library_panel.php views/pages/admin/medias/page.php
git commit -m "feat(medias): panneau médiathèque (import/choisir/utiliser/renommer/supprimer)"
```

---

## Task 6: Validation finale

- [ ] **Step 1: Tests existants verts**

Run: `"$PHP" vendor/bin/phpunit --filter "MediaResolverTest|MediaSlotsRegistryTest|ImageUploadServiceTest"`
Expected: OK (aucune régression ; les 4 erreurs `ClassScheduleServiceTest` sont préexistantes et hors périmètre).

- [ ] **Step 2: Analyse + lint**

Run: `"$PHP" vendor/bin/phpstan analyse src --level=6` → pas de nouvelle erreur sur les fichiers créés/modifiés.
Run: `"$PHP" -l` sur chaque fichier touché.

- [ ] **Step 3: Revue des critères d'acceptation (spec §6)** — cocher les 8 critères en parcourant l'admin.

- [ ] **Step 4: RAPPEL migration prod**

⚠ Appliquer `034_create_media_library.sql` à la main sur la BDD (local + prod) **avant** de tester la médiathèque. Sans la table, le panneau reste vide et l'écran ne plante pas (try/catch dans `page()` + `registerUpload()`).

---

## Notes de cohérence (auto-revue)

- **Couverture spec :** table 034 (T1), repository CRUD + garde (T2), assignFromLibrary + import/rename/delete + récupération auto via hooks + tolérance table absente (T3), routes (T4), panneau 2 colonnes + import/choisir/utiliser/renommer/supprimer + JS d'armement (T5), critères d'acceptation (T6). CSRF global + `csrf_field()` partout ; sécurité P7 réutilisée ; `slot_key` revalidée dans `assignFromLibrary`.
- **Noms cohérents :** repo `MediaLibraryRepository::listAll/findByID/create/registerIfAbsent/rename/delete/isReferenced` ; contrôleur `assignFromLibrary/libraryImport/libraryRename/libraryDelete` + privés `imageDims/registerUpload/normalizeFiles/mediasReturn/slotDefPageExists`. Le partial reçoit `library` + `pageKey`. Les formulaires « Utiliser ici » ont `slot_key` rempli par JS depuis l'emplacement armé.
- **Point d'attention impl :** vérifier le format exact des routes voisines avant insertion (simple espace) ; si phpstan exige un type de retour sur `imageDims`/`normalizeFiles`, annoter (`@return array{0:?int,1:?int}` / `@return list<array<string,mixed>>`).
```
