feat #58 : wizard multi-étapes création/édition d'article

Remplace le formulaire unique par un wizard 5 étapes (création) et
6 étapes (édition) avec auto-sauvegarde en brouillon, détection de
tags depuis le texte (TagSuggester), aperçu SEO, diff avant validation
et plan Markdown dynamique dans l'éditeur.

Détail des changements :
- ArticleManager : +6 méthodes (updatePartialMeta, saveDraftOverlay,
  getDraftOverlay, hasDraftOverlay, discardDraftOverlay, commitDraftOverlay)
- .htaccess : routes /new/{uuid}/{1-5} et /edit/{uuid}/{1-6}
- index.php : cases create et edit réécrits en switch($step),
  nouveau case autosave_draft et edit_discard_draft
- assets/js/wizard.js : autosave debounce, auto-resize textarea,
  scroll curseur, plan TOC dynamique, toggle pills tags
- templates/wizard/ : nav.php + step1..6.php

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 21:46:11 +02:00
parent 24bb244352
commit 6895a3bf65
11 changed files with 1422 additions and 166 deletions
+146
View File
@@ -0,0 +1,146 @@
<?php
// Attendu : $mode, $step, $totalSteps, $uuid, $seoTitle, $seoDescription, $autoSeoDesc,
// $postSlug, $published, $published_at, $category, $existingFiles (pour create), $article (edit)
ob_start();
$_backUrl = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/4' : '/edit/' . rawurlencode($uuid) . '/4';
$_formAction = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/5' : '/edit/' . rawurlencode($uuid) . '/5';
$_base = rtrim(APP_URL, '/');
$_effTitle = ($seoTitle !== '') ? $seoTitle : ($title ?? '');
$_effDesc = ($seoDescription !== '') ? $seoDescription : $autoSeoDesc;
$_coverFile = ($article ?? [])['cover'] ?? '';
$_pubTs = strtotime((string)($published_at ?? ''));
$_pubFmt = $_pubTs ? date('d/m/Y H:i', $_pubTs) : '—';
$_catVal = trim($category ?? '');
?>
<form method="POST" action="<?= htmlspecialchars($_formAction) ?>">
<div class="d-flex align-items-center justify-content-between gap-3 mb-4 flex-wrap">
<h1 class="h4 mb-0">SEO<?= $mode === 'create' ? ' & Validation' : '' ?></h1>
<div class="d-flex gap-2">
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
<?php if ($mode === 'create'): ?>
<button type="submit" class="btn btn-success">✓ Publier l'article</button>
<?php else: ?>
<button type="submit" class="btn btn-primary">Suivant →</button>
<?php endif; ?>
</div>
</div>
<?php include __DIR__ . '/nav.php'; ?>
<div class="row g-4">
<!-- ─── Colonne gauche : aperçu ──────────────────────────────────────────── -->
<div class="col-lg-5">
<div class="card border-secondary mb-3">
<div class="card-header bg-transparent py-2">
<span class="fw-semibold small">Aperçu moteur de recherche</span>
</div>
<div class="card-body p-3">
<div class="seo-preview mb-3">
<div class="seo-preview-url small text-truncate mb-1" id="preview-url">
<?= htmlspecialchars($_base . '/post/' . ($postSlug ?? '')) ?>
</div>
<div class="seo-preview-title mb-1" id="preview-title">
<?= htmlspecialchars($_effTitle) ?>
</div>
<div class="seo-preview-desc small" id="preview-desc">
<?= htmlspecialchars($_effDesc) ?>
</div>
</div>
<table class="table table-sm table-borderless mb-0 small">
<tbody>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Statut</th>
<td><?= ($published ?? false) ? '<span class="text-success">Public</span>' : '<span class="text-warning">Brouillon</span>' ?></td>
</tr>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Date</th>
<td><?= htmlspecialchars($_pubFmt) ?></td>
</tr>
<?php if ($_catVal !== ''): ?>
<tr>
<th class="text-muted fw-normal ps-0 pe-2 text-nowrap">Catégorie</th>
<td><?= htmlspecialchars($_catVal) ?></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- ─── Colonne droite : formulaire SEO ─────────────────────────────────── -->
<div class="col-lg-7">
<div class="card mb-4">
<div class="card-header bg-transparent py-2 fw-semibold small">Métadonnées SEO</div>
<div class="card-body">
<div class="mb-3">
<label for="seo_title" class="form-label">Titre SEO <small class="text-muted">(og:title, &lt;title&gt;)</small></label>
<input type="text" class="form-control" id="seo_title" name="seo_title"
maxlength="70"
value="<?= htmlspecialchars($seoTitle ?? '') ?>"
placeholder="<?= htmlspecialchars($title ?? '') ?>">
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Idéal : 3060 caractères</small>
<small id="seo_title_counter" class="text-muted">0 / 60</small>
</div>
</div>
<div class="mb-3">
<label for="seo_description" class="form-label">Description SEO <small class="text-muted">(meta description)</small></label>
<textarea class="form-control" id="seo_description" name="seo_description"
rows="3" maxlength="200"
placeholder="<?= htmlspecialchars(mb_strimwidth($autoSeoDesc ?? '', 0, 80, '…')) ?>"><?= htmlspecialchars($seoDescription ?? '') ?></textarea>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Idéal : 120155 caractères</small>
<small id="seo_desc_counter" class="text-muted">0 / 155</small>
</div>
</div>
<?php if ($mode === 'create' && !empty($existingFiles ?? [])): ?>
<?php $_imgFiles = array_filter($existingFiles, fn ($_f) => $_f['is_image']); ?>
<?php if ($_imgFiles): ?>
<div class="mb-0">
<label class="form-label">Image de couverture (og:image)</label>
<div class="d-flex flex-wrap gap-2">
<?php foreach ($_imgFiles as $_f):
$_fUrl = '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($_f['name']);
?>
<label class="position-relative" style="cursor:pointer">
<input type="radio" name="cover_file" value="<?= htmlspecialchars($_f['name']) ?>"
class="position-absolute" style="opacity:0" <?= ($_f['name'] === $_coverFile) ? 'checked' : '' ?>>
<img src="<?= htmlspecialchars($_fUrl) ?>" alt=""
style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:3px solid transparent;transition:border-color .15s"
class="wz-cover-thumb <?= ($_f['name'] === $_coverFile) ? 'wz-cover-selected' : '' ?>">
</label>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
</div>
</div>
</div><!-- /row -->
</form>
<style>
.seo-preview{border:1px solid #dee2e6;border-radius:6px;padding:10px 12px;background:#fff}
.seo-preview-url{color:#006621;font-size:.78rem}
.seo-preview-title{color:#1a0dab;font-size:1.05rem;font-weight:500;line-height:1.3;word-break:break-word}
.seo-preview-desc{color:#545454;line-height:1.5}
.wz-cover-selected{border-color:#0d6efd !important}
</style>
<div id="pc-data" hidden
data-default-title="<?= htmlspecialchars($_effTitle) ?>"
data-default-desc="<?= htmlspecialchars($_effDesc) ?>"
data-base-url="<?= htmlspecialchars($_base . '/post/') ?>"></div>
<script src="/assets/js/post_confirm.js"></script>
<script src="/assets/js/wizard.js"></script>
<?php
$content = ob_get_clean();
$title = ($mode === 'create' ? 'SEO & Validation' : 'SEO') . ' — Étape 5/' . $totalSteps;
include BASE_PATH . '/templates/layout.php';