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

Merged
cedricAbonnel merged 5 commits from feat/wizard-multi-step into main 2026-05-14 19:50:33 +00:00
18 changed files with 1526 additions and 173 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -8,6 +8,10 @@ if (!defined('BASE_PATH')) {
if (session_status() === PHP_SESSION_NONE) { if (session_status() === PHP_SESSION_NONE) {
$isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; $isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
$sessionName = $_ENV['SESSION_NAME'] ?? (getenv('SESSION_NAME') ?: null);
if ($sessionName !== null && $sessionName !== '') {
session_name($sessionName);
}
session_set_cookie_params([ session_set_cookie_params([
'lifetime' => 0, 'lifetime' => 0,
'path' => '/', 'path' => '/',
+25
View File
@@ -37,3 +37,28 @@ $dateValue = $published_at ?? date('Y-m-d\TH:i');
## Permissions serveur ## Permissions serveur
PHP-FPM tourne en `www-data`. Les fichiers sensibles (`.env`) appartiennent à `cedrix:www-data 640`. Voir `PROJET.md` § Permissions serveur. PHP-FPM tourne en `www-data`. Les fichiers sensibles (`.env`) appartiennent à `cedrix:www-data 640`. Voir `PROJET.md` § Permissions serveur.
## Configuration PHP-FPM recommandée
Sur un serveur 2 GB RAM, chaque worker PHP-FPM consomme ~40 MB. Pool recommandé (`/etc/php/8.3/fpm/pool.d/<site>.conf`) :
```ini
pm = dynamic
pm.max_children = 20
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 8
```
Symptôme de saturation : `server reached pm.max_children` dans `/var/log/php8.3-fpm.log`.
## Protection contre les bots (anciennes URLs DokuWiki)
Les anciens sites migrés depuis DokuWiki reçoivent du trafic de bots sur `/lib/`, `/doku.php`, etc. Utiliser `RedirectMatch 410` dans Apache plutôt que `Require all denied` — le 410 "Gone" est un signal définitif qui pousse les moteurs à retirer ces URLs de leur index.
```apache
# Dans le VirtualHost
RedirectMatch 410 "^/(lib|doku\.php|feed\.php|install\.php|_media|_detail)(/.*)?$"
```
Un 403 ("accès refusé") est ignoré par les bots sérieux qui continuent de réessayer. Un 410 ("disparu définitivement") les fait arrêter.
+7
View File
@@ -3,6 +3,10 @@ DirectoryIndex index.php
RewriteEngine On RewriteEngine On
# Paramètres DokuWiki (?do=media, ?do=export_pdf, etc.) — 410 Gone, jamais de contenu ici
RewriteCond %{QUERY_STRING} (^|&)do= [NC]
RewriteRule ^ - [R=410,L]
# Fichiers et répertoires réels servis directement # Fichiers et répertoires réels servis directement
RewriteCond %{REQUEST_FILENAME} -f [OR] RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d RewriteCond %{REQUEST_FILENAME} -d
@@ -19,7 +23,10 @@ RewriteRule ^page/([0-9a-f-]{36})/?$ /index.php?cursor=$1 [L,QSA]
# Édition / création # Édition / création
RewriteRule ^edit/([0-9a-f-]{36})/tags/(.+?)/?$ /index.php?action=edit_tags&uuid=$1&tag_type=$2 [L,QSA,B] RewriteRule ^edit/([0-9a-f-]{36})/tags/(.+?)/?$ /index.php?action=edit_tags&uuid=$1&tag_type=$2 [L,QSA,B]
RewriteRule ^edit/([0-9a-f-]{36})/discard/?$ /index.php?action=edit_discard_draft&uuid=$1 [L,QSA]
RewriteRule ^edit/([0-9a-f-]{36})/([1-6])/?$ /index.php?action=edit&uuid=$1&step=$2 [L,QSA]
RewriteRule ^edit/([0-9a-f-]{36})/?$ /index.php?action=edit&uuid=$1 [L,QSA] RewriteRule ^edit/([0-9a-f-]{36})/?$ /index.php?action=edit&uuid=$1 [L,QSA]
RewriteRule ^new/([0-9a-f-]{36})/([1-5])/?$ /index.php?action=create&uuid=$1&step=$2 [L,QSA]
RewriteRule ^new/?$ /index.php?action=create [L,QSA] RewriteRule ^new/?$ /index.php?action=create [L,QSA]
RewriteRule ^delete/([0-9a-f-]{36})/?$ /index.php?action=delete&uuid=$1 [L,QSA] RewriteRule ^delete/([0-9a-f-]{36})/?$ /index.php?action=delete&uuid=$1 [L,QSA]
+298
View File
@@ -0,0 +1,298 @@
// wizard.js — autosave, insertions, couleur catégorie, génération slug
document.addEventListener('DOMContentLoaded', function () {
var page = document.getElementById('vl-page');
var uuid = page ? page.dataset.uuid : '';
var autosaveUrl = page ? page.dataset.autosaveUrl : '';
// ─── Auto-resize textarea + scroll curseur ──────────────────────────────
var ta = document.getElementById('wz-content');
if (ta) {
function resizeTa() { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 'px'; }
ta.addEventListener('input', resizeTa);
resizeTa();
function scrollToCursor() {
var lineH = parseFloat(getComputedStyle(ta).lineHeight) || 20;
var padT = parseFloat(getComputedStyle(ta).paddingTop) || 8;
var lines = ta.value.substr(0, ta.selectionStart).split('\n').length;
var cursorY = ta.getBoundingClientRect().top + padT + lines * lineH;
var margin = lineH * 3;
if (cursorY > window.innerHeight - margin) {
window.scrollBy({ top: cursorY - window.innerHeight + margin, behavior: 'instant' });
} else if (cursorY < margin) {
window.scrollBy({ top: cursorY - margin, behavior: 'instant' });
}
}
ta.addEventListener('keyup', scrollToCursor);
ta.addEventListener('click', scrollToCursor);
}
// ─── Ctrl+Enter soumet le formulaire ────────────────────────────────────
var form = document.querySelector('form[method="POST"]');
if (form) {
form.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { form.submit(); }
});
}
// ─── Génération slug automatique (étape 1 / création) ───────────────────
var titleInput = document.getElementById('wz-title');
var slugField = document.getElementById('slug');
var slugPreview = document.getElementById('slug-preview');
function slugify(s) {
var map = {'à':'a','â':'a','ä':'a','é':'e','è':'e','ê':'e','ë':'e','î':'i','ï':'i','ô':'o','ö':'o','ù':'u','û':'u','ü':'u','ç':'c','æ':'ae','œ':'oe'};
return s.toLowerCase()
.replace(/[àâäéèêëîïôöùûüçæœ]/g, function(c) { return map[c] || c; })
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
if (titleInput && slugField) {
if (slugField.value !== '') slugField._auto = false;
titleInput.addEventListener('input', function () {
if (slugField._auto !== false) {
var gen = slugify(this.value);
slugField.value = gen;
if (slugPreview) slugPreview.textContent = gen;
}
});
slugField.addEventListener('input', function () {
this._auto = (this.value === '');
if (slugPreview) slugPreview.textContent = this.value;
});
}
// ─── Autosave ────────────────────────────────────────────────────────────
var indicator = document.getElementById('autosave-indicator');
if (indicator && uuid && autosaveUrl) {
var timer = null;
var titleEl = document.getElementById('wz-title');
var contentEl = document.getElementById('wz-content');
function scheduleAutosave() {
clearTimeout(timer);
timer = setTimeout(doAutosave, 3000);
}
async function doAutosave() {
if (!titleEl || !contentEl) return;
indicator.textContent = 'Sauvegarde…';
try {
var res = await fetch(autosaveUrl, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
title: titleEl.value,
content: contentEl.value,
slug: slugField ? slugField.value : '',
}),
});
var data = await res.json();
indicator.textContent = data.ok ? 'Brouillon sauvegardé à ' + data.time : 'Erreur de sauvegarde';
} catch (err) {
indicator.textContent = 'Erreur de sauvegarde';
}
}
if (titleEl) titleEl.addEventListener('input', scheduleAutosave);
if (ta) ta.addEventListener('input', scheduleAutosave);
}
// ─── Insertion Markdown depuis miniatures ────────────────────────────────
var insertUrl = page ? page.dataset.insertUrl : '';
document.querySelectorAll('[data-insert-ref]').forEach(function (el) {
el.addEventListener('click', function () {
if (!ta) return;
var ref = this.dataset.insertRef;
var isImage = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(ref);
var md = isImage ? '![](' + ref + ')' : '[' + ref + '](' + ref + ')';
var sep = ta.value.length > 0 && !ta.value.endsWith('\n') ? '\n' : '';
ta.value += sep + md;
ta.focus();
ta.selectionStart = ta.selectionEnd = ta.value.length;
ta.dispatchEvent(new Event('input'));
});
});
if (insertUrl) {
var isImg = /\.(jpe?g|png|gif|webp|svg|avif)(\?.*)?$/i.test(insertUrl);
var name = decodeURIComponent(insertUrl.split('/').pop().split('?')[0]) || 'fichier';
var ref = isImg ? '![](' + insertUrl + ')' : '[' + name + '](' + insertUrl + ')';
if (ta) {
var sep = ta.value.length > 0 && !ta.value.endsWith('\n') ? '\n' : '';
ta.value += sep + ref;
ta.dispatchEvent(new Event('input'));
}
}
// ─── Copier référence Markdown (bouton MD dans la liste des fichiers) ────
document.querySelectorAll('[data-copy-md-name]').forEach(function (btn) {
btn.addEventListener('click', function () {
if (!ta) return;
var name = this.dataset.copyMdName;
var isImage = this.dataset.copyMdIsImage === '1';
var md = isImage ? '![](' + name + ')' : '[' + name + '](' + name + ')';
var sep = ta.value.length > 0 && !ta.value.endsWith('\n') ? '\n' : '';
ta.value += sep + md;
ta.focus();
ta.dispatchEvent(new Event('input'));
});
});
// ─── Aperçu couleur catégorie (étape 3) ─────────────────────────────────
var KNOWN_CATS = {
'actualité': 10, 'travaux': 35, 'scolaire': 55,
'linux': 120, 'domotique': 160, 'télécom': 190,
'blog': 220, 'informatique': 255, 'réflexion': 285,
'loisirs': 320, 'perso': 345,
};
var FREE_HUES = [87, 140, 205, 237, 302];
var catInput = document.getElementById('category');
var catSwatch = document.getElementById('cat-swatch');
var catHint = document.getElementById('cat-hint');
var catSwatches = document.getElementById('cat-free-swatches');
function catHue(name) {
var key = name.toLowerCase().trim();
if (KNOWN_CATS[key] !== undefined) return KNOWN_CATS[key];
var h = 0;
for (var i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) & 0xffff;
return h % 360;
}
function updateCatSwatch() {
if (!catInput || !catSwatch) return;
var v = catInput.value.trim();
if (v === '') {
catSwatch.style.background = '#e5e7eb';
catSwatch.title = '';
if (catHint) catHint.textContent = '';
} else {
var hue = catHue(v);
catSwatch.style.background = 'hsl(' + hue + ',55%,52%)';
catSwatch.title = 'hsl(' + hue + ', 55%, 52%)';
if (catHint) {
var known = KNOWN_CATS[v.toLowerCase()] !== undefined;
catHint.textContent = known ? 'Couleur fixe' : 'Nouvelle catégorie (couleur générée)';
}
}
}
if (catInput) {
catInput.addEventListener('input', updateCatSwatch);
updateCatSwatch();
if (catSwatches) {
FREE_HUES.forEach(function (h) {
var sw = document.createElement('span');
sw.style.cssText = 'display:inline-block;width:20px;height:20px;border-radius:4px;cursor:pointer;background:hsl(' + h + ',55%,52%)';
sw.title = 'hsl(' + h + ', 55%, 52%)';
sw.addEventListener('click', function () {
// trouver ou créer le nom correspondant
catInput.dispatchEvent(new Event('input'));
});
catSwatches.appendChild(sw);
});
}
}
// ─── Plan (TOC dynamique) ────────────────────────────────────────────────
var tocList = document.getElementById('wz-toc-list');
if (tocList && ta) {
function buildToc() {
var lines = ta.value.split('\n');
var items = [];
lines.forEach(function (line) {
var m = line.match(/^(#{1,6})\s+(.+)/);
if (m) { items.push({ level: m[1].length, text: m[2].trim() }); }
});
if (items.length === 0) {
tocList.innerHTML = '<li class="small text-muted fst-italic px-1">Aucun titre</li>';
return;
}
var minLevel = Math.min.apply(null, items.map(function (i) { return i.level; }));
tocList.innerHTML = items.map(function (item) {
var indent = (item.level - minLevel) * 12;
var escaped = item.text.replace(/&/g,'&amp;').replace(/</g,'&lt;');
return '<li class="small text-truncate py-0" style="padding-left:' + indent + 'px" title="' + escaped + '">'
+ '<span class="text-muted me-1" style="font-size:.65rem">H' + item.level + '</span>'
+ escaped + '</li>';
}).join('');
}
ta.addEventListener('input', buildToc);
buildToc();
}
// ─── Sélection catégorie — pills .wz-cat-pick (étape 3) ─────────────────
document.querySelectorAll('.wz-cat-pick').forEach(function (btn) {
btn.addEventListener('click', function () {
var catInp = document.getElementById('category');
if (catInp) {
catInp.value = this.dataset.cat;
catInp.dispatchEvent(new Event('input'));
}
document.querySelectorAll('.wz-cat-pick').forEach(function (b) { b.classList.remove('active'); });
this.classList.add('active');
});
});
// ─── Toggle tags — pills .wz-tag-pill (étape 4) ──────────────────────────
document.querySelectorAll('.wz-tag-pills').forEach(function (container) {
var targetId = container.dataset.target;
var inp = document.getElementById(targetId);
if (!inp) return;
container.querySelectorAll('.wz-tag-pill').forEach(function (pill) {
pill.addEventListener('click', function () {
var val = this.dataset.value;
var parts = inp.value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
var idx = parts.indexOf(val);
if (idx >= 0) {
parts.splice(idx, 1);
this.classList.remove('btn-secondary', 'btn-info');
this.classList.add(this.classList.contains('btn-outline-info') ? 'btn-outline-info' : 'btn-outline-secondary');
} else {
parts.push(val);
var isDetected = this.classList.contains('btn-outline-info') || this.classList.contains('btn-info');
this.classList.remove('btn-outline-secondary', 'btn-outline-info');
this.classList.add(isDetected ? 'btn-info' : 'btn-secondary');
}
inp.value = parts.join(', ');
});
});
});
// ─── Image de couverture .wz-cover-thumb (étape 5) ───────────────────────
document.querySelectorAll('.wz-cover-thumb').forEach(function (img) {
img.addEventListener('click', function () {
document.querySelectorAll('.wz-cover-thumb').forEach(function (i) { i.classList.remove('wz-cover-selected'); });
this.classList.add('wz-cover-selected');
});
});
// ─── Compteurs SEO (étape 5) ──────────────────────────────────────────────
(function () {
function counter(inputId, counterId, warn) {
var el = document.getElementById(inputId);
var ct = document.getElementById(counterId);
if (!el || !ct) return;
function upd() { var l = el.value.length; ct.textContent = l + ' / ' + warn; ct.className = 'small ' + (l > warn ? 'text-danger' : (l < warn * 0.5 ? 'text-muted' : 'text-success')); }
el.addEventListener('input', upd);
upd();
}
counter('seo_title', 'seo_title_counter', 60);
counter('seo_description', 'seo_desc_counter', 155);
}());
// ─── Confirmation data-confirm ────────────────────────────────────────────
document.querySelectorAll('[data-confirm]').forEach(function (el) {
el.addEventListener('click', function (e) {
if (!confirm(this.dataset.confirm)) e.preventDefault();
});
});
});
+345 -168
View File
@@ -4,16 +4,24 @@ declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../')); define('BASE_PATH', realpath(__DIR__ . '/../'));
if (session_status() === PHP_SESSION_NONE) { // Charger .env avant de lire SESSION_NAME, sinon getenv() retourne '' et le mauvais cookie est chargé
require_once BASE_PATH . '/vendor/autoload.php';
require_once BASE_PATH . '/config/config.php';
$_sessionName = $_ENV['SESSION_NAME'] ?? (getenv('SESSION_NAME') ?: 'PHPSESSID');
if (session_status() === PHP_SESSION_NONE
&& (isset($_COOKIE[$_sessionName]) || $_SERVER['REQUEST_METHOD'] === 'POST')
) {
$isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; $isHttps = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
session_name($_sessionName);
session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Lax']); session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Lax']);
session_start(); session_start();
} }
unset($_sessionName);
require_once BASE_PATH . '/src/helpers.php'; require_once BASE_PATH . '/src/helpers.php';
require_once BASE_PATH . '/src/auth.php'; require_once BASE_PATH . '/src/auth.php';
require_once BASE_PATH . '/src/SiteSettings.php'; require_once BASE_PATH . '/src/SiteSettings.php';
require_once BASE_PATH . '/config/config.php';
require_once BASE_PATH . '/src/ArticleManager.php'; require_once BASE_PATH . '/src/ArticleManager.php';
$articles = new ArticleManager(BASE_PATH . '/data'); $articles = new ArticleManager(BASE_PATH . '/data');
@@ -483,56 +491,165 @@ switch ($action) {
case 'create': case 'create':
requireAuth(); requireAuth();
$title = $_POST['title'] ?? ''; $step = max(1, min(5, (int)($_GET['step'] ?? 1)));
$content = $_POST['content'] ?? ''; $totalSteps = 5;
$postSlug = $_POST['slug'] ?? ''; $mode = 'create';
$published = isset($_POST['published']); $errors = [];
$published_at = str_replace('T', ' ', $_POST['published_at'] ?? date('Y-m-d H:i:s'));
$seoTitle = $_POST['seo_title'] ?? '';
$seoDescription = $_POST['seo_description'] ?? '';
$ogImage = $_POST['og_image'] ?? '';
$category = $_POST['category'] ?? '';
$errors = [];
$postTags = []; // UUID depuis l'URL ou la session
foreach (($_POST['tags'] ?? []) as $_tk => $_tv) { if ($uuid === '') {
$_vals = array_values(array_filter(array_map('trim', explode(',', (string)$_tv)), fn ($v) => $v !== '')); $uuid = $_SESSION['wizard_create'] ?? '';
if ($_vals !== []) { }
$postTags[trim((string)$_tk)] = $_vals; $draft = $uuid !== '' ? $articles->getByUuid($uuid) : null;
}
// Si session pointe vers un UUID inexistant, on repart de zéro
if ($draft === null && $uuid !== '') {
unset($_SESSION['wizard_create']);
$uuid = '';
} }
if ($_SERVER['REQUEST_METHOD'] === 'POST') { switch ($step) {
if (trim($title) === '') {
$errors[] = 'Le titre est obligatoire.';
}
if (empty($errors)) {
$newUuid = $articles->create($title, $content, $published, $postSlug, $published_at, currentUserEmail() ?? '', $seoTitle, $seoDescription, $ogImage, $category, $postTags);
foreach ($_FILES['files']['tmp_name'] ?? [] as $i => $tmpName) { case 1:
if ($_FILES['files']['error'][$i] === UPLOAD_ERR_OK) { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$articles->addFile($newUuid, [ $title = trim($_POST['title'] ?? '');
'name' => $_FILES['files']['name'][$i], $content = $_POST['content'] ?? '';
'tmp_name' => $tmpName, $postSlug = trim($_POST['slug'] ?? '');
'error' => $_FILES['files']['error'][$i], if ($title === '') {
]); $errors[] = 'Le titre est obligatoire.';
} else {
if ($draft === null) {
$uuid = $articles->create($title, $content, false, $postSlug, date('Y-m-d H:i:s'), currentUserEmail() ?? '', '', '', '', '', []);
foreach ($_FILES['files']['tmp_name'] ?? [] as $_fi => $_tmpName) {
if ($_FILES['files']['error'][$_fi] === UPLOAD_ERR_OK) {
$articles->addFile($uuid, ['name' => $_FILES['files']['name'][$_fi], 'tmp_name' => $_tmpName, 'error' => UPLOAD_ERR_OK]);
}
}
$_SESSION['wizard_create'] = $uuid;
} else {
$articles->autosave($uuid, $title, $content, $postSlug);
}
header('Location: /new/' . rawurlencode($uuid) . '/2');
exit;
} }
} }
$title = $draft['title'] ?? ($_POST['title'] ?? '');
$content = $draft['content'] ?? '';
$postSlug = $draft['slug'] ?? '';
$existingFiles = $uuid !== '' ? $articles->getFiles($uuid) : [];
$article = $draft;
$insertUrl = '';
$formAction = $uuid !== '' ? '/new/' . rawurlencode($uuid) . '/1' : '/new';
include BASE_PATH . '/templates/wizard/step1.php';
break;
header('Location: /'); case 2:
exit; if ($draft === null) {
} header('Location: /new');
} exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$articles->updatePartialMeta($uuid, [
'published' => isset($_POST['published']) && $_POST['published'] !== '',
'published_at' => str_replace('T', ' ', $_POST['published_at'] ?? date('Y-m-d H:i:s')),
]);
header('Location: /new/' . rawurlencode($uuid) . '/3');
exit;
}
$published = (bool)($draft['published'] ?? false);
$published_at = $draft['published_at'] ?? date('Y-m-d H:i:s');
include BASE_PATH . '/templates/wizard/step2.php';
break;
$formAction = '/new'; case 3:
$action = 'create'; if ($draft === null) {
$tagTypes = $articles->getTagTypes(); header('Location: /new');
$articleTags = $postTags; exit;
$allTagValues = []; }
foreach ($tagTypes as $_tk => $_) { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$allTagValues[$_tk] = $articles->getAllTagValues($_tk); $articles->updatePartialMeta($uuid, ['category' => trim($_POST['category'] ?? '')]);
header('Location: /new/' . rawurlencode($uuid) . '/4');
exit;
}
$category = $draft['category'] ?? '';
$allCategories = $articles->getCategories();
$privateCats = $articles->getPrivateCategories();
include BASE_PATH . '/templates/wizard/step3.php';
break;
case 4:
if ($draft === null) {
header('Location: /new');
exit;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$_vals = array_values(array_filter(array_map('trim', explode(',', (string)($_POST['tags_flat'] ?? ''))), fn ($_v) => $_v !== ''));
$articles->updatePartialMeta($uuid, ['tags' => $_vals !== [] ? ['tags' => $_vals] : []]);
header('Location: /new/' . rawurlencode($uuid) . '/5');
exit;
}
$_tagTypes = $articles->getTagTypes();
$flatTagValues = [];
foreach ($_tagTypes as $_tk => $_) {
foreach ($articles->getAllTagValues($_tk) as $_v) {
$flatTagValues[$_v] = true;
}
}
foreach ($articles->getAllTagValues('tags') as $_v) {
$flatTagValues[$_v] = true;
}
ksort($flatTagValues);
$flatTagValues = array_keys($flatTagValues);
$flatArticleTags = [];
foreach (($draft['tags'] ?? []) as $_tagVals) {
foreach ((array)$_tagVals as $_v) {
if (!in_array($_v, $flatArticleTags, true)) {
$flatArticleTags[] = $_v;
}
}
}
$draftContent = (string)($draft['content'] ?? '');
require_once BASE_PATH . '/src/TagSuggester.php';
include BASE_PATH . '/templates/wizard/step4.php';
break;
case 5:
if ($draft === null) {
header('Location: /new');
exit;
}
require_once BASE_PATH . '/src/Parsedown.php';
$_pd = new Parsedown();
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…');
unset($_pd);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$seoTitle = trim($_POST['seo_title'] ?? '');
$seoDesc = trim($_POST['seo_description'] ?? '') ?: $autoSeoDesc;
$coverFile = trim($_POST['cover_file'] ?? '') ?: ($draft['cover'] ?? '');
$ogImage = $coverFile !== ''
? rtrim(APP_URL, '/') . '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($coverFile)
: ($draft['og_image'] ?? '');
$articles->update($uuid, $draft['title'], $draft['content'], $draft['published'], $draft['slug'] ?? '', $draft['published_at'] ?? '', 'Création', $seoTitle, $seoDesc, $ogImage, $draft['category'] ?? '', $draft['tags'] ?? []);
if ($coverFile !== '' && $coverFile !== ($draft['cover'] ?? '')) {
$articles->setCover($uuid, $coverFile);
}
unset($_SESSION['wizard_create']);
$final = $articles->getByUuid($uuid);
header('Location: /post/' . rawurlencode($final['slug'] ?? $uuid));
exit;
}
$title = $draft['title'] ?? '';
$seoTitle = $draft['seo_title'] ?? '';
$seoDescription = $draft['seo_description'] ?? '';
$existingFiles = $articles->getFiles($uuid);
$category = $draft['category'] ?? '';
$published = (bool)($draft['published'] ?? false);
$published_at = $draft['published_at'] ?? '';
$postSlug = $draft['slug'] ?? '';
$article = $draft;
include BASE_PATH . '/templates/wizard/step5.php';
break;
} }
include BASE_PATH . '/templates/post_form.php';
break; break;
case 'view': case 'view':
@@ -713,165 +830,191 @@ switch ($action) {
echo 'Article introuvable.'; echo 'Article introuvable.';
exit; exit;
} }
if (!canDoOnArticle('edit_articles', $article)) { if (!canDoOnArticle('edit_articles', $article)) {
http_response_code(403); http_response_code(403);
echo 'Accès refusé.'; echo 'Accès refusé.';
exit; exit;
} }
// Toggle featured (admin only) — conservé depuis l'ancienne version
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['_toggle_featured'])) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['_toggle_featured'])) {
if (!isAdmin()) { if (!isAdmin()) {
http_response_code(403); http_response_code(403);
exit; exit;
} }
$articles->setFeatured($uuid, !((bool)($article['featured'] ?? false))); $articles->setFeatured($uuid, !((bool)($article['featured'] ?? false)));
header('Location: /edit/' . rawurlencode($uuid)); header('Location: /edit/' . rawurlencode($uuid) . '/1');
exit; exit;
} }
$title = $_POST['title'] ?? $article['title']; $step = (int)($_GET['step'] ?? 0);
$content = $_POST['content'] ?? $article['content']; $totalSteps = 6;
$postSlug = $_POST['slug'] ?? $article['slug']; $mode = 'edit';
$published = isset($_POST['published']) ? true : $article['published']; $errors = [];
$published_at = $_POST['published_at']
?? date('Y-m-d\TH:i', strtotime((string)($article['published_at'] ?? 'now')));
$seoTitle = $_POST['seo_title'] ?? ($article['seo_title'] ?? '');
$seoDescription = $_POST['seo_description'] ?? ($article['seo_description'] ?? '');
$ogImage = $_POST['og_image'] ?? ($article['og_image'] ?? '');
$category = $_POST['category'] ?? ($article['category'] ?? '');
$errors = [];
// Tags : lire depuis POST si soumis, sinon depuis l'article // Sans step : rediriger vers l'étape 1
$pendingTags = []; if ($step === 0) {
if ($_SERVER['REQUEST_METHOD'] === 'POST') { header('Location: /edit/' . rawurlencode($uuid) . '/1');
foreach (($_POST['tags'] ?? []) as $_tk => $_tv) { exit;
$_vals = array_values(array_filter(array_map('trim', explode(',', (string)$_tv)), fn ($v) => $v !== ''));
if ($_vals !== []) {
$pendingTags[trim((string)$_tk)] = $_vals;
}
}
} else {
$pendingTags = $article['tags'] ?? [];
} }
if ($_SERVER['REQUEST_METHOD'] === 'POST') { // Base de travail : draft overlay s'il existe, sinon article original
if (trim($title) === '') { $draft = $articles->getDraftOverlay($uuid) ?? $article;
$errors[] = 'Le titre est obligatoire.';
}
if (empty($errors)) {
if (!empty($_POST['_confirm'])) {
$coverFile = trim($_POST['cover_file'] ?? '') ?: ($article['cover'] ?? '');
$ogImageFromCover = $coverFile !== ''
? rtrim(APP_URL, '/') . '/file?uuid=' . rawurlencode($uuid) . '&name=' . rawurlencode($coverFile)
: '';
$articles->update( switch ($step) {
$uuid,
$title,
$content,
$published,
$_POST['slug'] ?? '',
str_replace('T', ' ', $_POST['published_at'] ?? ''),
$_POST['revision_comment'] ?? '',
$_POST['seo_title'] ?? '',
$_POST['seo_description'] ?? '',
$ogImageFromCover,
$_POST['category'] ?? '',
$pendingTags
);
$fmetaNames = $_POST['fmeta_name'] ?? []; case 1:
$fmetaAuthors = $_POST['fmeta_author'] ?? []; if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$fmetaSources = $_POST['fmeta_source'] ?? []; $title = trim($_POST['title'] ?? '');
foreach ($fmetaNames as $fi => $fname) { $content = $_POST['content'] ?? '';
$articles->addFileMeta($uuid, $fname, trim($fmetaAuthors[$fi] ?? ''), trim($fmetaSources[$fi] ?? '')); if ($title === '') {
$errors[] = 'Le titre est obligatoire.';
} else {
$articles->saveDraftOverlay($uuid, ['title' => $title, 'slug' => trim($_POST['slug'] ?? $draft['slug'])], $content);
header('Location: /edit/' . rawurlencode($uuid) . '/2');
exit;
} }
}
$title = $draft['title'];
$content = $draft['content'];
$postSlug = $draft['slug'];
$existingFiles = $articles->getFiles($uuid);
$insertUrl = '';
if (isset($_GET['insert_url']) && filter_var($_GET['insert_url'], FILTER_VALIDATE_URL)) {
$insertUrl = $_GET['insert_url'];
}
$formAction = '/edit/' . rawurlencode($uuid) . '/1';
include BASE_PATH . '/templates/wizard/step1.php';
break;
$coverFile = trim($_POST['cover_file'] ?? ''); case 2:
if ($coverFile !== '') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$articles->setCover($uuid, $coverFile); $articles->saveDraftOverlay($uuid, [
} 'published' => isset($_POST['published']) && $_POST['published'] !== '',
'published_at' => str_replace('T', ' ', $_POST['published_at'] ?? ($draft['published_at'] ?? '')),
$updated = $articles->getByUuid($uuid); ]);
header('Location: /post/' . rawurlencode($updated['slug'] ?? $uuid)); header('Location: /edit/' . rawurlencode($uuid) . '/3');
exit; exit;
} }
$published = (bool)($draft['published'] ?? false);
$published_at = $draft['published_at'] ?? '';
include BASE_PATH . '/templates/wizard/step2.php';
break;
// ─── Page de confirmation ──────────────────────────────────── case 3:
$diffLines = lineDiff((string)($article['content'] ?? ''), $content); if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$titleChanged = ($title !== ($article['title'] ?? '')); $articles->saveDraftOverlay($uuid, ['category' => trim($_POST['category'] ?? '')]);
$autoSlug = slugify($title); header('Location: /edit/' . rawurlencode($uuid) . '/4');
exit;
}
$category = $draft['category'] ?? '';
$allCategories = $articles->getCategories();
$privateCats = $articles->getPrivateCategories();
include BASE_PATH . '/templates/wizard/step3.php';
break;
$changes = []; case 4:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$_vals = array_values(array_filter(array_map('trim', explode(',', (string)($_POST['tags_flat'] ?? ''))), fn ($_v) => $_v !== ''));
$articles->saveDraftOverlay($uuid, ['tags' => $_vals !== [] ? ['tags' => $_vals] : []]);
header('Location: /edit/' . rawurlencode($uuid) . '/5');
exit;
}
$_tagTypes = $articles->getTagTypes();
$flatTagValues = [];
foreach ($_tagTypes as $_tk => $_) {
foreach ($articles->getAllTagValues($_tk) as $_v) {
$flatTagValues[$_v] = true;
}
}
foreach ($articles->getAllTagValues('tags') as $_v) {
$flatTagValues[$_v] = true;
}
ksort($flatTagValues);
$flatTagValues = array_keys($flatTagValues);
$flatArticleTags = [];
foreach (($draft['tags'] ?? []) as $_tagVals) {
foreach ((array)$_tagVals as $_v) {
if (!in_array($_v, $flatArticleTags, true)) {
$flatArticleTags[] = $_v;
}
}
}
$draftContent = (string)($draft['content'] ?? '');
require_once BASE_PATH . '/src/TagSuggester.php';
include BASE_PATH . '/templates/wizard/step4.php';
break;
case 5:
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$articles->saveDraftOverlay($uuid, [
'seo_title' => trim($_POST['seo_title'] ?? ''),
'seo_description' => trim($_POST['seo_description'] ?? ''),
]);
header('Location: /edit/' . rawurlencode($uuid) . '/6');
exit;
}
require_once BASE_PATH . '/src/Parsedown.php';
$_pd = new Parsedown();
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draft['content'] ?? ''))))), 0, 155, '…');
unset($_pd);
$title = $draft['title'];
$seoTitle = $draft['seo_title'] ?? '';
$seoDescription = $draft['seo_description'] ?? '';
$postSlug = $draft['slug'];
$published = (bool)($draft['published'] ?? false);
$published_at = $draft['published_at'] ?? '';
$category = $draft['category'] ?? '';
include BASE_PATH . '/templates/wizard/step5.php';
break;
case 6:
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['_confirm'])) {
$revisionComment = trim($_POST['revision_comment'] ?? '');
// Si le slug a été modifié dans le formulaire de confirmation, le propager
if (!empty($_POST['slug'])) {
$articles->saveDraftOverlay($uuid, ['slug' => trim($_POST['slug'])]);
}
$articles->commitDraftOverlay($uuid, $revisionComment);
$final = $articles->getByUuid($uuid);
header('Location: /post/' . rawurlencode($final['slug'] ?? $uuid));
exit;
}
$draftData = $articles->getDraftOverlay($uuid) ?? $article;
require_once BASE_PATH . '/src/Parsedown.php';
$_pd = new Parsedown();
$autoSeoDesc = mb_strimwidth(trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text((string)($draftData['content'] ?? ''))))), 0, 155, '…');
unset($_pd);
$diffLines = lineDiff((string)($article['content'] ?? ''), (string)($draftData['content'] ?? ''));
$titleChanged = ($draftData['title'] ?? '') !== ($article['title'] ?? '');
$autoSlug = slugify($draftData['title'] ?? '');
$postSlug = $draftData['slug'] ?? $article['slug'];
$changes = [];
if ($titleChanged) { if ($titleChanged) {
$changes[] = 'titre modifié'; $changes[] = 'titre modifié';
} }
if (($category ?? '') !== ($article['category'] ?? '')) { if (($draftData['category'] ?? '') !== ($article['category'] ?? '')) {
$changes[] = 'catégorie modifiée'; $changes[] = 'catégorie modifiée';
} }
if ($pendingTags !== ($article['tags'] ?? [])) { if (($draftData['tags'] ?? []) !== ($article['tags'] ?? [])) {
$changes[] = 'tags modifiés'; $changes[] = 'tags modifiés';
} }
if ($content !== ($article['content'] ?? '')) { if (($draftData['content'] ?? '') !== ($article['content'] ?? '')) {
$changes[] = 'contenu modifié'; $changes[] = 'contenu modifié';
} }
$oldPublished = (bool)($article['published'] ?? false); if ((bool)($draftData['published'] ?? false) !== (bool)($article['published'] ?? false)) {
if ($published !== $oldPublished) { $changes[] = ($draftData['published'] ?? false) ? 'article publié' : 'article dépublié';
$changes[] = $published ? 'article publié' : 'article dépublié';
}
$newCover = trim($_POST['cover_file'] ?? '');
if ($newCover !== '' && $newCover !== ($article['cover'] ?? '')) {
$changes[] = 'couverture modifiée';
}
$fmetaNames = $_POST['fmeta_name'] ?? [];
$fmetaAuthors = $_POST['fmeta_author'] ?? [];
$fmetaSources = $_POST['fmeta_source'] ?? [];
foreach ($fmetaNames as $fi => $fname) {
$savedMeta = ($article['files_meta'][$fname] ?? []);
if (trim($fmetaAuthors[$fi] ?? '') !== ($savedMeta['author'] ?? '')
|| trim($fmetaSources[$fi] ?? '') !== ($savedMeta['source_url'] ?? '')) {
$changes[] = 'métadonnées fichiers modifiées';
break;
}
} }
$autoRevisionComment = !empty($changes) ? ucfirst(implode(', ', $changes)) : ''; $autoRevisionComment = !empty($changes) ? ucfirst(implode(', ', $changes)) : '';
$title = $draftData['title'] ?? '';
require_once BASE_PATH . '/src/Parsedown.php'; $seoTitle = $draftData['seo_title'] ?? '';
$_pd = new Parsedown(); $seoDescription = $draftData['seo_description'] ?? '';
$autoSeoDesc = mb_strimwidth( $published = (bool)($draftData['published'] ?? false);
trim((string)preg_replace('/\s+/', ' ', strip_tags($_pd->text($content)))), $published_at = $draftData['published_at'] ?? '';
0, $category = $draftData['category'] ?? '';
155, include BASE_PATH . '/templates/wizard/step6.php';
'…' break;
);
unset($_pd);
// Tags sous forme de chaînes CSV pour les champs hidden du formulaire de confirmation
$confirmTags = [];
foreach ($pendingTags as $_tk => $_vals) {
$confirmTags[$_tk] = implode(', ', $_vals);
}
include BASE_PATH . '/templates/post_confirm.php';
exit;
}
} }
$formAction = '/edit/' . rawurlencode($uuid);
$action = 'edit';
$existingFiles = $articles->getFiles($uuid);
$insertUrl = '';
if (isset($_GET['insert_url']) && filter_var($_GET['insert_url'], FILTER_VALIDATE_URL)) {
$insertUrl = $_GET['insert_url'];
}
$tagTypes = $articles->getTagTypes();
$articleTags = $pendingTags;
$allTagValues = [];
foreach ($tagTypes as $_tk => $_) {
$allTagValues[$_tk] = $articles->getAllTagValues($_tk);
}
include BASE_PATH . '/templates/post_form.php';
break; break;
case 'edit_tags': case 'edit_tags':
@@ -1333,6 +1476,40 @@ switch ($action) {
echo json_encode(['ok' => $ok, 'time' => date('H:i:s')]); echo json_encode(['ok' => $ok, 'time' => date('H:i:s')]);
exit; exit;
case 'autosave_draft':
requireAuth();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uuid === '') {
echo json_encode(['ok' => false]);
exit;
}
$_adArticle = $articles->getByUuid($uuid);
if (!$_adArticle || !canDoOnArticle('edit_articles', $_adArticle)) {
echo json_encode(['ok' => false]);
exit;
}
$_adTitle = trim($_POST['title'] ?? '');
$_adContent = $_POST['content'] ?? null;
if ($_adTitle === '') {
echo json_encode(['ok' => false]);
exit;
}
$articles->saveDraftOverlay($uuid, ['title' => $_adTitle], $_adContent);
echo json_encode(['ok' => true, 'time' => date('H:i:s')]);
exit;
case 'edit_discard_draft':
requireAuth();
$_ddArticle = $articles->getByUuid($uuid);
if (!$_ddArticle || !canDoOnArticle('edit_articles', $_ddArticle)) {
http_response_code(403);
echo 'Accès refusé.';
exit;
}
$articles->discardDraftOverlay($uuid);
header('Location: /post/' . rawurlencode($_ddArticle['slug'] ?? $uuid));
exit;
case 'copy_file': case 'copy_file':
requireAuth(); requireAuth();
header('Content-Type: application/json'); header('Content-Type: application/json');
@@ -1888,10 +2065,10 @@ switch ($action) {
exit; exit;
} }
// CSRF // CSRF (double-submit cookie — pas de session requise pour les visiteurs)
$csrfOk = isset($_POST['_token'], $_SESSION['comment_csrf']) $csrfOk = isset($_POST['_token'], $_COOKIE['_csrf_c'])
&& hash_equals($_SESSION['comment_csrf'], $_POST['_token']); && hash_equals($_COOKIE['_csrf_c'], $_POST['_token']);
unset($_SESSION['comment_csrf']); setcookie('_csrf_c', '', ['expires' => time() - 3600, 'path' => '/', 'samesite' => 'Strict', 'httponly' => true]);
if (!$csrfOk) { if (!$csrfOk) {
header('Location: /'); header('Location: /');
exit; exit;
+4 -1
View File
@@ -2,9 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
if (!defined('BASE_PATH')) {
define('BASE_PATH', dirname(__DIR__, 2));
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php'; require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php'; require_once dirname(__DIR__, 2) . '/config/config.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
if (!function_exists('env')) { if (!function_exists('env')) {
function env(string $key, ?string $default = null): ?string function env(string $key, ?string $default = null): ?string
+4 -1
View File
@@ -4,9 +4,12 @@
// version : 20251005 // version : 20251005
declare(strict_types=1); declare(strict_types=1);
if (!defined('BASE_PATH')) {
define('BASE_PATH', dirname(__DIR__, 2));
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php'; require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php'; require_once dirname(__DIR__, 2) . '/config/config.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
function maskToken(?string $t): string function maskToken(?string $t): string
{ {
+4 -1
View File
@@ -2,9 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
if (!defined('BASE_PATH')) {
define('BASE_PATH', dirname(__DIR__, 2));
}
require_once dirname(__DIR__, 2) . '/vendor/autoload.php'; require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
require_once dirname(__DIR__, 2) . '/config/config.php'; require_once dirname(__DIR__, 2) . '/config/config.php';
require_once dirname(__DIR__, 2) . '/bootstrap.php';
if (!function_exists('env')) { if (!function_exists('env')) {
function env(string $key, ?string $default = null): ?string function env(string $key, ?string $default = null): ?string
+127
View File
@@ -228,6 +228,126 @@ class ArticleManager
return true; return true;
} }
public function updatePartialMeta(string $uuid, array $updates): void
{
if (!$this->isValidUuid($uuid)) {
return;
}
$dir = $this->dataDir . '/' . $uuid;
$raw = @file_get_contents($dir . '/meta.json');
if ($raw === false) {
return;
}
$meta = json_decode($raw, true);
if (!is_array($meta)) {
return;
}
foreach ($updates as $key => $value) {
$meta[$key] = $value;
}
$meta['updated_at'] = date('Y-m-d H:i:s');
$this->writeMeta($dir, $meta);
}
public function saveDraftOverlay(string $uuid, array $metaFields, ?string $content = null): void
{
if (!$this->isValidUuid($uuid)) {
return;
}
$dir = $this->dataDir . '/' . $uuid;
$existing = [];
$raw = @file_get_contents($dir . '/draft_overlay.json');
if ($raw !== false) {
$existing = json_decode($raw, true) ?? [];
}
$overlay = array_merge($existing, $metaFields);
$overlay['_updated_at'] = date('Y-m-d H:i:s');
file_put_contents(
$dir . '/draft_overlay.json',
json_encode($overlay, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
);
if ($content !== null) {
file_put_contents($dir . '/draft_overlay.md', $content);
}
}
public function getDraftOverlay(string $uuid): ?array
{
if (!$this->isValidUuid($uuid)) {
return null;
}
$dir = $this->dataDir . '/' . $uuid;
if (!file_exists($dir . '/draft_overlay.json')) {
return null;
}
$article = $this->getByUuid($uuid);
if (!$article) {
return null;
}
$raw = file_get_contents($dir . '/draft_overlay.json');
if ($raw === false) {
return null;
}
$overlay = json_decode($raw, true);
if (!is_array($overlay)) {
return null;
}
$merged = $article;
foreach ($overlay as $key => $value) {
if (!str_starts_with($key, '_')) {
$merged[$key] = $value;
}
}
if (file_exists($dir . '/draft_overlay.md')) {
$c = file_get_contents($dir . '/draft_overlay.md');
if ($c !== false) {
$merged['content'] = $c;
}
}
return $merged;
}
public function hasDraftOverlay(string $uuid): bool
{
if (!$this->isValidUuid($uuid)) {
return false;
}
return file_exists($this->dataDir . '/' . $uuid . '/draft_overlay.json');
}
public function discardDraftOverlay(string $uuid): void
{
if (!$this->isValidUuid($uuid)) {
return;
}
$dir = $this->dataDir . '/' . $uuid;
@unlink($dir . '/draft_overlay.json');
@unlink($dir . '/draft_overlay.md');
}
public function commitDraftOverlay(string $uuid, string $revisionComment = ''): void
{
$draft = $this->getDraftOverlay($uuid);
if (!$draft) {
return;
}
$this->update(
$uuid,
$draft['title'],
$draft['content'],
(bool)$draft['published'],
$draft['slug'] ?? '',
$draft['published_at'] ?? '',
$revisionComment,
$draft['seo_title'] ?? '',
$draft['seo_description'] ?? '',
$draft['og_image'] ?? '',
$draft['category'] ?? '',
$draft['tags'] ?? []
);
$this->discardDraftOverlay($uuid);
}
public function addFileMeta(string $uuid, string $filename, string $author, string $sourceUrl, string $title = '', array $extraMeta = []): void public function addFileMeta(string $uuid, string $filename, string $author, string $sourceUrl, string $title = '', array $extraMeta = []): void
{ {
if (!$this->isValidUuid($uuid)) { if (!$this->isValidUuid($uuid)) {
@@ -867,6 +987,13 @@ class ArticleManager
$this->rebuildSearchIndex(); $this->rebuildSearchIndex();
return $this->searchIndexCache; return $this->searchIndexCache;
} }
// Rebuild si des UUID ont été supprimés hors CMS (ex. rsync, suppression manuelle)
foreach ($data as $entry) {
if (!is_dir($this->dataDir . '/' . ($entry['uuid'] ?? ''))) {
$this->rebuildSearchIndex();
return $this->searchIndexCache;
}
}
$this->searchIndexCache = $data; $this->searchIndexCache = $data;
return $this->searchIndexCache; return $this->searchIndexCache;
} }
+7 -1
View File
@@ -15,7 +15,13 @@ $_reactionDefs = [
]; ];
$_csrfToken = bin2hex(random_bytes(16)); $_csrfToken = bin2hex(random_bytes(16));
$_SESSION['comment_csrf'] = $_csrfToken; setcookie('_csrf_c', $_csrfToken, [
'expires' => 0,
'path' => '/',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
'httponly' => true,
'samesite' => 'Strict',
]);
?> ?>
<?php if (!empty($alsoReadArticles ?? [])): ?> <?php if (!empty($alsoReadArticles ?? [])): ?>
+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';
+105
View File
@@ -0,0 +1,105 @@
<?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';