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:
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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, <title>)</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 : 30–60 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 : 120–155 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';
|
||||
@@ -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">− <?= htmlspecialchars($line) ?></div>
|
||||
<?php elseif ($op === '+'): ?>
|
||||
<div class="diff-ins px-2">+ <?= 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"> <?= 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';
|
||||
Reference in New Issue
Block a user