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
+182
View File
@@ -0,0 +1,182 @@
<?php
// Attendu : $mode, $step, $totalSteps, $uuid, $formAction, $title, $content (article),
// $existingFiles, $insertUrl, $article, $errors
ob_start();
$_wizUuid = $uuid ?? '';
$_backHref = '/';
$_hasUuid = $_wizUuid !== '';
?>
<div id="vl-page"
data-uuid="<?= htmlspecialchars($_wizUuid) ?>"
data-insert-url="<?= htmlspecialchars($insertUrl ?? '') ?>"
data-autosave-url="<?= $mode === 'edit'
? '/?action=autosave_draft&uuid=' . rawurlencode($_wizUuid)
: '/?action=autosave&uuid=' . rawurlencode($_wizUuid) ?>"
hidden></div>
<form method="POST" action="<?= htmlspecialchars($formAction) ?>" enctype="multipart/form-data">
<!-- En-tête avec boutons ────────────────────────────────────────────────── -->
<div class="d-flex align-items-center justify-content-between gap-3 mb-4 flex-wrap">
<div>
<h1 class="h4 mb-0"><?= $mode === 'create' ? 'Nouvel article' : htmlspecialchars('Modifier — ' . ($article['title'] ?? '')) ?></h1>
<?php if ($_hasUuid): ?>
<span id="autosave-indicator" class="text-muted small"></span>
<?php endif; ?>
</div>
<div class="d-flex gap-2 align-items-center">
<a href="<?= htmlspecialchars($_backHref) ?>" class="btn btn-outline-secondary btn-sm">Annuler</a>
<button type="submit" class="btn btn-primary">Suivant →</button>
</div>
</div>
<?php if (!empty($errors)): ?>
<div class="alert alert-danger mb-3"><ul class="mb-0"><?php foreach ($errors as $_e): ?><li><?= htmlspecialchars($_e) ?></li><?php endforeach; ?></ul></div>
<?php endif; ?>
<?php include __DIR__ . '/nav.php'; ?>
<div class="row g-3 align-items-start">
<div class="col-lg-9">
<!-- Titre ─────────────────────────────────────────────────────────────── -->
<div class="mb-3">
<label for="wz-title" class="form-label fw-semibold">Titre</label>
<input type="text" class="form-control form-control-lg" id="wz-title" name="title" required
value="<?= htmlspecialchars($title ?? '') ?>"
placeholder="Titre de l'article…">
</div>
<!-- Contenu ──────────────────────────────────────────────────────────── -->
<div class="mb-3">
<label for="wz-content" class="form-label fw-semibold">Contenu <small class="text-muted fw-normal">(Markdown)</small></label>
<textarea class="form-control font-monospace" id="wz-content" name="content" rows="18"
style="min-height:320px"><?= htmlspecialchars($content ?? '') ?></textarea>
</div>
</div><!-- /col-lg-9 -->
<!-- Plan (TOC dynamique) ───────────────────────────────────────────────── -->
<div class="col-lg-3 d-none d-lg-block">
<div class="position-sticky" style="top:1rem">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent py-2 small fw-semibold text-muted">Plan</div>
<div class="card-body p-2" style="max-height:80vh;overflow-y:auto">
<ul id="wz-toc-list" class="list-unstyled mb-0"></ul>
</div>
</div>
</div>
</div>
</div><!-- /row -->
<!-- Fichiers / Import ─────────────────────────────────────────────────── -->
<?php if (!$_hasUuid): ?>
<div class="mb-4">
<label for="files" class="form-label fw-semibold">Ajouter des fichiers <small class="text-muted fw-normal">(optionnel)</small></label>
<input type="file" class="form-control" id="files" name="files[]" multiple>
<div class="form-text">Les fichiers seront attachés à l'article après création.</div>
</div>
<?php else: ?>
<div class="d-flex flex-wrap gap-2 mb-4">
<a href="/files/<?= rawurlencode($_wizUuid) ?>/add?back=<?= rawurlencode($formAction) ?>"
class="btn btn-outline-secondary">+ Ajouter des fichiers</a>
<a href="/import/<?= rawurlencode($_wizUuid) ?>?back=<?= rawurlencode($formAction) ?>"
class="btn btn-outline-secondary">+ Importer depuis une URL</a>
</div>
<?php if (!empty($existingFiles)): ?>
<?php $_coverFile = ($article ?? [])['cover'] ?? ''; ?>
<div class="mb-3">
<p class="fw-semibold small mb-2">Fichiers attachés (<?= count($existingFiles) ?>)</p>
<div class="d-flex flex-wrap gap-2">
<?php foreach ($existingFiles as $_fi => $_f):
$_fUrl = '/file?uuid=' . rawurlencode($_wizUuid) . '&name=' . rawurlencode($_f['name']);
$_isCover = ($_f['name'] === $_coverFile);
?>
<div class="border rounded p-1 d-flex align-items-center gap-2" style="max-width:220px">
<?php if ($_f['is_image']): ?>
<img src="<?= htmlspecialchars($_fUrl) ?>" alt="" style="width:36px;height:36px;object-fit:cover;border-radius:3px;flex-shrink:0<?= $_isCover ? ';outline:2px solid #0d6efd' : '' ?>">
<?php else: ?>
<span style="width:36px;text-align:center;font-size:1.1rem;flex-shrink:0"><?= match(true) {
str_starts_with($_f['mime'], 'video/') => '🎬',
str_starts_with($_f['mime'], 'audio/') => '🎵',
$_f['mime'] === 'application/pdf' => '📑',
default => '📄',
} ?></span>
<?php endif; ?>
<div class="overflow-hidden flex-grow-1" style="min-width:0">
<div class="text-truncate small"><?= htmlspecialchars($_f['name']) ?></div>
<div class="d-flex gap-1 mt-1">
<button type="button" class="btn btn-xs btn-outline-secondary"
data-copy-md-name="<?= htmlspecialchars($_f['name']) ?>"
data-copy-md-is-image="<?= $_f['is_image'] ? '1' : '0' ?>"
style="font-size:.65rem;padding:.1rem .35rem">MD</button>
<button type="submit" form="del-file-wz-<?= $_fi ?>"
class="btn btn-xs btn-outline-danger"
data-confirm="Supprimer « <?= htmlspecialchars($_f['name']) ?> » ?"
style="font-size:.65rem;padding:.1rem .35rem">✕</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php $_sidebarImgs = array_filter($existingFiles ?? [], fn ($_f) => $_f['is_image']); ?>
<?php if ($_sidebarImgs): ?>
<div class="mb-3">
<p class="fw-semibold small mb-1">Images <span class="text-muted fw-normal">(clic → insère dans le contenu)</span></p>
<div class="d-flex flex-wrap gap-2">
<?php foreach ($_sidebarImgs as $_img):
$_iUrl = '/file?uuid=' . rawurlencode($_wizUuid) . '&name=' . rawurlencode($_img['name']);
?>
<img src="<?= htmlspecialchars($_iUrl) ?>"
alt="<?= htmlspecialchars($_img['name']) ?>"
title="<?= htmlspecialchars($_img['name']) ?>"
data-insert-ref="<?= htmlspecialchars($_img['name']) ?>"
style="width:64px;height:64px;object-fit:cover;border-radius:5px;cursor:pointer;border:2px solid transparent;transition:border-color .15s">
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php $_extLinks = ($article ?? [])['external_links'] ?? []; ?>
<?php if ($_extLinks): ?>
<div class="mb-3">
<p class="fw-semibold small mb-1">Liens externes</p>
<ul class="list-group list-group-flush" style="max-width:480px">
<?php foreach ($_extLinks as $_el): ?>
<li class="list-group-item px-0 py-1 d-flex align-items-center gap-2 border-0 border-bottom">
<span class="flex-grow-1 text-truncate small"
data-insert-ref="<?= htmlspecialchars($_el['url']) ?>"
style="cursor:pointer;color:#0d6efd;text-decoration:underline dotted"
title="<?= htmlspecialchars($_el['url']) ?>"><?= htmlspecialchars($_el['name']) ?></span>
<form method="POST" action="/?action=delete_external_link&uuid=<?= rawurlencode($_wizUuid) ?>" class="d-inline flex-shrink-0">
<input type="hidden" name="url" value="<?= htmlspecialchars($_el['url']) ?>">
<button type="submit" class="btn btn-link btn-sm text-danger p-0 lh-1"
data-confirm="Supprimer ce lien ?" title="Supprimer">✕</button>
</form>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<?php endif; ?>
<?php endif; ?>
</form>
<?php if (!empty($existingFiles)): ?>
<?php foreach ($existingFiles as $_fi => $_f): ?>
<form id="del-file-wz-<?= $_fi ?>" method="POST"
action="/?action=delete_file&uuid=<?= rawurlencode($_wizUuid) ?>&_back=<?= rawurlencode($formAction) ?>">
<input type="hidden" name="name" value="<?= htmlspecialchars($_f['name']) ?>">
</form>
<?php endforeach; ?>
<?php endif; ?>
<script src="/assets/js/wizard.js"></script>
<?php
$content = ob_get_clean();
$title = ($mode === 'create' ? 'Nouvel article' : 'Modifier') . ' — Étape 1/' . $totalSteps;
include BASE_PATH . '/templates/layout.php';