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
+36
View File
@@ -0,0 +1,36 @@
<?php
// Attendu : $step (int), $totalSteps (int), $mode ('create'|'edit'), $uuid (string)
$_wizLabels = $mode === 'create'
? ['Contenu', 'Publication', 'Catégorie', 'Tags', 'SEO & Validation']
: ['Contenu', 'Publication', 'Catégorie', 'Tags', 'SEO', 'Diff & Validation'];
$_base = $mode === 'create' ? '/new/' . rawurlencode($uuid ?? '') : '/edit/' . rawurlencode($uuid ?? '');
?>
<nav class="wizard-nav mb-4">
<div class="d-flex align-items-center gap-1 flex-wrap">
<?php foreach ($_wizLabels as $_wi => $_wl):
$_wn = $_wi + 1;
$_wActive = ($_wn === $step);
$_wDone = ($_wn < $step);
$_wHref = ($_wDone && ($uuid ?? '') !== '') ? htmlspecialchars($_base . '/' . $_wn) : null;
?>
<?php if ($_wi > 0): ?>
<span class="wizard-sep text-muted px-1"></span>
<?php endif; ?>
<div class="wizard-step<?= $_wActive ? ' wz-active' : ($_wDone ? ' wz-done' : ' wz-upcoming') ?>">
<?php if ($_wHref): ?><a href="<?= $_wHref ?>" class="wz-link"><?php endif; ?>
<span class="wz-num"><?= $_wDone ? '✓' : $_wn ?></span>
<span class="wz-label d-none d-sm-inline"><?= htmlspecialchars($_wl) ?></span>
<?php if ($_wHref): ?></a><?php endif; ?>
</div>
<?php endforeach; ?>
</div>
</nav>
<style>
.wizard-nav{border-bottom:1px solid var(--bs-border-color,#dee2e6);padding-bottom:.75rem}
.wizard-step{display:inline-flex;align-items:center;gap:.3rem;padding:.25rem .5rem;border-radius:.4rem;font-size:.85rem}
.wz-active{background:#0d6efd;color:#fff;font-weight:600}
.wz-done{color:#198754}.wz-done .wz-link{color:#198754;text-decoration:none}
.wz-upcoming{color:var(--bs-secondary-color,#6c757d)}
.wz-num{display:inline-flex;align-items:center;justify-content:center;width:1.4rem;height:1.4rem;border-radius:50%;border:1.5px solid currentColor;font-size:.75rem;flex-shrink:0}
.wz-active .wz-num{border-color:#fff}
</style>
+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';
+58
View File
@@ -0,0 +1,58 @@
<?php
ob_start();
$_dateVal = isset($published_at)
? (str_contains((string)$published_at, ' ')
? date('Y-m-d\TH:i', strtotime((string)$published_at))
: (string)$published_at)
: date('Y-m-d\TH:i');
$_backUrl = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/1' : '/edit/' . rawurlencode($uuid) . '/1';
$_formAction = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/2' : '/edit/' . rawurlencode($uuid) . '/2';
?>
<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">Publication</h1>
<div class="d-flex gap-2">
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
<button type="submit" class="btn btn-primary">Suivant →</button>
</div>
</div>
<?php include __DIR__ . '/nav.php'; ?>
<div class="row justify-content-start">
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-body">
<div class="mb-4">
<p class="fw-semibold mb-2">Visibilité</p>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="published" id="pub-yes" value="1"
<?= ($published ?? false) ? 'checked' : '' ?>>
<label class="form-check-label" for="pub-yes">Public</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="published" id="pub-no" value=""
<?= !($published ?? false) ? 'checked' : '' ?>>
<label class="form-check-label" for="pub-no">Brouillon (privé)</label>
</div>
<div class="form-text mt-1">Un brouillon n'est visible que par les utilisateurs authentifiés.</div>
</div>
<div>
<label for="published_at" class="form-label fw-semibold">Date de publication</label>
<input type="datetime-local" class="form-control" id="published_at" name="published_at"
value="<?= htmlspecialchars($_dateVal) ?>">
<div class="form-text">Une date future crée une avant-première (visible aux utilisateurs avec la capacité <code>view_previews</code>).</div>
</div>
</div>
</div>
</div>
</div>
</form>
<?php
$content = ob_get_clean();
$title = 'Publication — Étape 2/' . $totalSteps;
include BASE_PATH . '/templates/layout.php';
+66
View File
@@ -0,0 +1,66 @@
<?php
ob_start();
$_backUrl = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/2' : '/edit/' . rawurlencode($uuid) . '/2';
$_formAction = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/3' : '/edit/' . rawurlencode($uuid) . '/3';
?>
<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">Catégorie</h1>
<div class="d-flex gap-2">
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
<button type="submit" class="btn btn-primary">Suivant →</button>
</div>
</div>
<?php include __DIR__ . '/nav.php'; ?>
<div class="row justify-content-start">
<div class="col-lg-6">
<div class="card mb-4">
<div class="card-body">
<div class="mb-3">
<label for="category" class="form-label fw-semibold">Catégorie</label>
<div class="d-flex align-items-center gap-2">
<input type="text" class="form-control" id="category" name="category"
value="<?= htmlspecialchars($category ?? '') ?>"
placeholder="ex : informatique, loisirs, photo…"
autocomplete="off">
<div id="cat-swatch" title="" style="width:40px;height:36px;border-radius:6px;flex-shrink:0;background:#e5e7eb;transition:background .25s"></div>
</div>
<small id="cat-hint" class="text-muted d-block mt-1"></small>
<div id="cat-free-swatches" class="d-flex flex-wrap gap-1 mt-2"></div>
</div>
<?php if (!empty($allCategories)): ?>
<div>
<p class="small text-muted mb-2">Catégories existantes :</p>
<div class="d-flex flex-wrap gap-2">
<?php foreach ($allCategories as $_cat => $_count):
$_isPriv = in_array($_cat, $privateCats ?? [], true);
?>
<button type="button" class="btn btn-sm btn-outline-secondary wz-cat-pick<?= ($_cat === ($category ?? '')) ? ' active' : '' ?>"
data-cat="<?= htmlspecialchars($_cat) ?>">
<?= htmlspecialchars($_cat) ?>
<span class="badge bg-secondary ms-1"><?= $_count ?></span>
<?php if ($_isPriv): ?><span title="Privée">🔒</span><?php endif; ?>
</button>
<?php endforeach; ?>
<button type="button" class="btn btn-sm btn-outline-secondary wz-cat-pick<?= (($category ?? '') === '') ? ' active' : '' ?>"
data-cat=""><em class="text-muted">Aucune</em></button>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
</form>
<script src="/assets/js/wizard.js"></script>
<?php
$content = ob_get_clean();
$title = 'Catégorie — Étape 3/' . $totalSteps;
include BASE_PATH . '/templates/layout.php';
+107
View File
@@ -0,0 +1,107 @@
<?php
// Attendu : $mode, $step, $totalSteps, $uuid, $flatTagValues, $flatArticleTags, $draftContent
ob_start();
$_backUrl = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/3' : '/edit/' . rawurlencode($uuid) . '/3';
$_formAction = $mode === 'create' ? '/new/' . rawurlencode($uuid) . '/4' : '/edit/' . rawurlencode($uuid) . '/4';
$_tagVal = implode(', ', $flatArticleTags);
$_suggester = new TagSuggester();
$_candidates = $draftContent !== ''
? $_suggester->suggest($draftContent, $flatTagValues, $flatArticleTags)
: [];
$_knownInText = array_keys(array_filter($_candidates, fn ($_c) => $_c['known']));
$_detectedInText = array_keys(array_filter($_candidates, fn ($_c) => !$_c['known']));
?>
<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">Tags</h1>
<div class="d-flex gap-2">
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
<button type="submit" class="btn btn-primary">Suivant →</button>
</div>
</div>
<?php include __DIR__ . '/nav.php'; ?>
<div class="card mb-4">
<div class="card-body">
<datalist id="wz-tags-list">
<?php foreach ($flatTagValues as $_v): ?>
<option value="<?= htmlspecialchars($_v) ?>">
<?php endforeach; ?>
</datalist>
<div class="mb-3">
<input type="text" class="form-control"
id="wz-tags-flat"
name="tags_flat"
value="<?= htmlspecialchars($_tagVal) ?>"
placeholder="valeur1, valeur2…"
list="wz-tags-list"
autocomplete="off">
<div class="form-text">Séparer par des virgules.</div>
</div>
<!-- Valeurs existantes ──────────────────────────────────────────────── -->
<?php if (!empty($flatTagValues)): ?>
<div class="mb-3">
<p class="small text-muted mb-2">Valeurs déjà utilisées :</p>
<div class="d-flex flex-wrap gap-1 wz-tag-pills" data-target="wz-tags-flat">
<?php foreach ($flatTagValues as $_v):
$_isActive = in_array($_v, $flatArticleTags, true);
$_inText = in_array($_v, $_knownInText, true);
?>
<button type="button"
class="btn btn-sm <?= $_isActive ? 'btn-secondary' : 'btn-outline-secondary' ?> wz-tag-pill py-0"
data-value="<?= htmlspecialchars($_v) ?>"
style="font-size:.75rem"
title="<?= $_inText ? 'Présent dans le texte' : '' ?>">
<?= htmlspecialchars($_v) ?><?= $_inText ? ' <span class="ms-1" style="opacity:.6;font-size:.65rem">●</span>' : '' ?>
</button>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- Détectés dans le texte ──────────────────────────────────────────── -->
<?php if (!empty($_detectedInText)): ?>
<div>
<p class="small text-muted mb-2">Détectés dans le texte (abréviations, noms propres, mots composés) :</p>
<div class="d-flex flex-wrap gap-1 wz-tag-pills" data-target="wz-tags-flat">
<?php foreach ($_detectedInText as $_v):
$_isActive = in_array($_v, $flatArticleTags, true);
$_meta = $_candidates[$_v];
$_badge = match($_meta['group'] ?? '') {
'abbrev' => 'ABR',
'camel' => 'CC',
'proper' => 'NP',
default => '',
};
?>
<button type="button"
class="btn btn-sm <?= $_isActive ? 'btn-info' : 'btn-outline-info' ?> wz-tag-pill py-0"
data-value="<?= htmlspecialchars($_v) ?>"
style="font-size:.75rem"
title="<?= htmlspecialchars($_meta['count'] . 'x dans le texte — ' . ($_badge ?: 'détecté')) ?>">
<?= htmlspecialchars($_v) ?>
<?php if ($_badge): ?>
<span class="ms-1 text-muted" style="font-size:.6rem"><?= $_badge ?></span>
<?php endif; ?>
</button>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
</form>
<script src="/assets/js/wizard.js"></script>
<?php
$content = ob_get_clean();
$title = 'Tags — Étape 4/' . $totalSteps;
include BASE_PATH . '/templates/layout.php';
+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';
+104
View File
@@ -0,0 +1,104 @@
<?php
// Attendu (edit only) : $uuid, $step, $totalSteps, $mode='edit', $article (original),
// $draftData, $diffLines, $changes, $autoRevisionComment,
// $seoTitle, $seoDescription, $autoSeoDesc, $title (draft), $postSlug,
// $titleChanged, $autoSlug, $published, $published_at, $category
ob_start();
$_CONTEXT = 3;
$_backUrl = '/edit/' . rawurlencode($uuid) . '/5';
$_formAction = '/edit/' . rawurlencode($uuid) . '/6';
$_slugFinal = ($titleChanged && $autoSlug !== $postSlug) ? $autoSlug : $postSlug;
?>
<?php include __DIR__ . '/nav.php'; ?>
<!-- En-tête : titre + boutons à droite ─────────────────────────────────── -->
<form method="POST" action="<?= htmlspecialchars($_formAction) ?>">
<input type="hidden" name="_confirm" value="1">
<input type="hidden" name="slug" value="<?= htmlspecialchars($_slugFinal) ?>">
<div class="d-flex align-items-start justify-content-between gap-3 mb-4 flex-wrap">
<div>
<h1 class="h4 mb-1">Confirmer les modifications</h1>
<?php if (!empty($changes)): ?>
<p class="text-muted small mb-0"><?= htmlspecialchars(ucfirst(implode(' · ', $changes))) ?></p>
<?php else: ?>
<p class="text-muted small mb-0">Aucune modification détectée.</p>
<?php endif; ?>
</div>
<div class="d-flex gap-2 flex-wrap align-items-center">
<a href="<?= htmlspecialchars($_backUrl) ?>" class="btn btn-outline-secondary btn-sm">← Retour</a>
<button type="button" class="btn btn-outline-danger btn-sm"
onclick="if(confirm('Abandonner les modifications et supprimer ce brouillon ?')) window.location='/edit/<?= rawurlencode($uuid) ?>/discard'">
Abandonner
</button>
<button type="submit" class="btn btn-success">✓ Confirmer et enregistrer</button>
</div>
</div>
<!-- Commentaire de révision ────────────────────────────────────────────── -->
<div class="mb-4" style="max-width:520px">
<label for="revision_comment" class="form-label fw-semibold">
Commentaire de révision <small class="text-muted fw-normal">(optionnel)</small>
</label>
<input type="text" class="form-control" id="revision_comment" name="revision_comment"
value="<?= htmlspecialchars($autoRevisionComment) ?>"
placeholder="ex. Correction typos, ajout section X…">
</div>
<!-- Diff contenu ────────────────────────────────────────────────────────── -->
<div class="mb-4">
<h2 class="h6 fw-semibold mb-2">Diff du contenu</h2>
<?php if ($diffLines === []): ?>
<div class="text-muted small">Contenu identique.</div>
<?php else:
$total = count($diffLines);
$show = [];
for ($i = 0; $i < $total; $i++) {
if ($diffLines[$i][0] !== '=') {
for ($c = max(0, $i - $_CONTEXT); $c <= min($total - 1, $i + $_CONTEXT); $c++) {
$show[$c] = true;
}
}
}
?>
<div class="d-flex gap-3 mb-1 small">
<span class="diff-del px-2 py-1 rounded"> Supprimé</span>
<span class="diff-ins px-2 py-1 rounded">+ Ajouté</span>
</div>
<div class="diff-view font-monospace small">
<?php $inEllipsis = false;
for ($i = 0; $i < $total; $i++):
[$op, $line] = $diffLines[$i];
?>
<?php if (!isset($show[$i])): ?>
<?php if (!$inEllipsis): $inEllipsis = true; ?>
<div class="diff-ellipsis text-muted px-2">⋯</div>
<?php endif; continue; ?>
<?php else: $inEllipsis = false; endif; ?>
<?php if ($op === '-'): ?>
<div class="diff-del px-2">&nbsp;<?= htmlspecialchars($line) ?></div>
<?php elseif ($op === '+'): ?>
<div class="diff-ins px-2">+&nbsp;<?= htmlspecialchars($line) ?></div>
<?php elseif ($op === '!'): ?>
<div class="diff-warning text-warning px-2"><?= htmlspecialchars($line) ?></div>
<?php else: ?>
<div class="diff-eq px-2 text-muted">&nbsp;&nbsp;<?= htmlspecialchars($line) ?></div>
<?php endif; ?>
<?php endfor; ?>
</div>
<?php endif; ?>
</div>
</form>
<style>
.diff-view{border:1px solid var(--bs-border-color,#dee2e6);border-radius:6px;overflow-x:auto}
.diff-view > div{padding:1px 8px;white-space:pre;line-height:1.5}
.diff-del{background:#ffeef0;color:#b91c1c}
.diff-ins{background:#e6ffec;color:#15803d}
.diff-ellipsis{background:#f8f9fa;padding:2px 8px;user-select:none}
</style>
<?php
$content = ob_get_clean();
$title = 'Valider les modifications — Étape 6/6';
include BASE_PATH . '/templates/layout.php';