diff --git a/public/.htaccess b/public/.htaccess
index 6219c46..ab70c0c 100644
--- a/public/.htaccess
+++ b/public/.htaccess
@@ -23,7 +23,10 @@ RewriteRule ^page/([0-9a-f-]{36})/?$ /index.php?cursor=$1 [L,QSA]
# É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})/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 ^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 ^delete/([0-9a-f-]{36})/?$ /index.php?action=delete&uuid=$1 [L,QSA]
diff --git a/public/assets/js/wizard.js b/public/assets/js/wizard.js
new file mode 100644
index 0000000..599277d
--- /dev/null
+++ b/public/assets/js/wizard.js
@@ -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 + ')';
+ 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 ? '' : '[' + 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 + ')';
+ 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 = '
Aucun titre';
+ 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,'&').replace(/'
+ + 'H' + item.level + ''
+ + escaped + '';
+ }).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();
+ });
+ });
+
+});
diff --git a/public/index.php b/public/index.php
index e58cdad..840c11d 100644
--- a/public/index.php
+++ b/public/index.php
@@ -4,7 +4,11 @@ declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
-$_sessionName = getenv('SESSION_NAME') ?: 'PHPSESSID';
+// 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')
) {
@@ -18,7 +22,6 @@ unset($_sessionName);
require_once BASE_PATH . '/src/helpers.php';
require_once BASE_PATH . '/src/auth.php';
require_once BASE_PATH . '/src/SiteSettings.php';
-require_once BASE_PATH . '/config/config.php';
require_once BASE_PATH . '/src/ArticleManager.php';
$articles = new ArticleManager(BASE_PATH . '/data');
@@ -488,56 +491,145 @@ switch ($action) {
case 'create':
requireAuth();
- $title = $_POST['title'] ?? '';
- $content = $_POST['content'] ?? '';
- $postSlug = $_POST['slug'] ?? '';
- $published = isset($_POST['published']);
- $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 = [];
+ $step = max(1, min(5, (int)($_GET['step'] ?? 1)));
+ $totalSteps = 5;
+ $mode = 'create';
+ $errors = [];
- $postTags = [];
- foreach (($_POST['tags'] ?? []) as $_tk => $_tv) {
- $_vals = array_values(array_filter(array_map('trim', explode(',', (string)$_tv)), fn ($v) => $v !== ''));
- if ($_vals !== []) {
- $postTags[trim((string)$_tk)] = $_vals;
- }
+ // UUID depuis l'URL ou la session
+ if ($uuid === '') {
+ $uuid = $_SESSION['wizard_create'] ?? '';
+ }
+ $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') {
- 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);
+ switch ($step) {
- foreach ($_FILES['files']['tmp_name'] ?? [] as $i => $tmpName) {
- if ($_FILES['files']['error'][$i] === UPLOAD_ERR_OK) {
- $articles->addFile($newUuid, [
- 'name' => $_FILES['files']['name'][$i],
- 'tmp_name' => $tmpName,
- 'error' => $_FILES['files']['error'][$i],
- ]);
+ case 1:
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $title = trim($_POST['title'] ?? '');
+ $content = $_POST['content'] ?? '';
+ $postSlug = trim($_POST['slug'] ?? '');
+ 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: /');
- exit;
- }
- }
+ case 2:
+ 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';
- $action = 'create';
- $tagTypes = $articles->getTagTypes();
- $articleTags = $postTags;
- $allTagValues = [];
- foreach ($tagTypes as $_tk => $_) {
- $allTagValues[$_tk] = $articles->getAllTagValues($_tk);
+ case 3:
+ if ($draft === null) { header('Location: /new'); exit; }
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $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;
case 'view':
@@ -718,165 +810,175 @@ switch ($action) {
echo 'Article introuvable.';
exit;
}
-
if (!canDoOnArticle('edit_articles', $article)) {
http_response_code(403);
echo 'Accès refusé.';
exit;
}
+ // Toggle featured (admin only) — conservé depuis l'ancienne version
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['_toggle_featured'])) {
if (!isAdmin()) {
http_response_code(403);
exit;
}
$articles->setFeatured($uuid, !((bool)($article['featured'] ?? false)));
- header('Location: /edit/' . rawurlencode($uuid));
+ header('Location: /edit/' . rawurlencode($uuid) . '/1');
exit;
}
- $title = $_POST['title'] ?? $article['title'];
- $content = $_POST['content'] ?? $article['content'];
- $postSlug = $_POST['slug'] ?? $article['slug'];
- $published = isset($_POST['published']) ? true : $article['published'];
- $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 = [];
+ $step = (int)($_GET['step'] ?? 0);
+ $totalSteps = 6;
+ $mode = 'edit';
+ $errors = [];
- // Tags : lire depuis POST si soumis, sinon depuis l'article
- $pendingTags = [];
- if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- foreach (($_POST['tags'] ?? []) as $_tk => $_tv) {
- $_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'] ?? [];
+ // Sans step : rediriger vers l'étape 1
+ if ($step === 0) {
+ header('Location: /edit/' . rawurlencode($uuid) . '/1');
+ exit;
}
- if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- if (trim($title) === '') {
- $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)
- : '';
+ // Base de travail : draft overlay s'il existe, sinon article original
+ $draft = $articles->getDraftOverlay($uuid) ?? $article;
- $articles->update(
- $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
- );
+ switch ($step) {
- $fmetaNames = $_POST['fmeta_name'] ?? [];
- $fmetaAuthors = $_POST['fmeta_author'] ?? [];
- $fmetaSources = $_POST['fmeta_source'] ?? [];
- foreach ($fmetaNames as $fi => $fname) {
- $articles->addFileMeta($uuid, $fname, trim($fmetaAuthors[$fi] ?? ''), trim($fmetaSources[$fi] ?? ''));
+ case 1:
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $title = trim($_POST['title'] ?? '');
+ $content = $_POST['content'] ?? '';
+ 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'] ?? '');
- if ($coverFile !== '') {
- $articles->setCover($uuid, $coverFile);
- }
-
- $updated = $articles->getByUuid($uuid);
- header('Location: /post/' . rawurlencode($updated['slug'] ?? $uuid));
+ case 2:
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $articles->saveDraftOverlay($uuid, [
+ 'published' => isset($_POST['published']) && $_POST['published'] !== '',
+ 'published_at' => str_replace('T', ' ', $_POST['published_at'] ?? ($draft['published_at'] ?? '')),
+ ]);
+ header('Location: /edit/' . rawurlencode($uuid) . '/3');
exit;
}
+ $published = (bool)($draft['published'] ?? false);
+ $published_at = $draft['published_at'] ?? '';
+ include BASE_PATH . '/templates/wizard/step2.php';
+ break;
- // ─── Page de confirmation ────────────────────────────────────
- $diffLines = lineDiff((string)($article['content'] ?? ''), $content);
- $titleChanged = ($title !== ($article['title'] ?? ''));
- $autoSlug = slugify($title);
+ case 3:
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $articles->saveDraftOverlay($uuid, ['category' => trim($_POST['category'] ?? '')]);
+ 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 = [];
- if ($titleChanged) {
- $changes[] = 'titre modifié';
+ 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;
}
- if (($category ?? '') !== ($article['category'] ?? '')) {
- $changes[] = 'catégorie modifiée';
+ $_tagTypes = $articles->getTagTypes();
+ $flatTagValues = [];
+ foreach ($_tagTypes as $_tk => $_) {
+ foreach ($articles->getAllTagValues($_tk) as $_v) { $flatTagValues[$_v] = true; }
}
- if ($pendingTags !== ($article['tags'] ?? [])) {
- $changes[] = 'tags modifiés';
+ 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; }
}
- if ($content !== ($article['content'] ?? '')) {
- $changes[] = 'contenu modifié';
- }
- $oldPublished = (bool)($article['published'] ?? false);
- if ($published !== $oldPublished) {
- $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)) : '';
+ $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($content)))),
- 0,
- 155,
- '…'
- );
+ $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;
- // 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);
+ 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;
}
-
- include BASE_PATH . '/templates/post_confirm.php';
- 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) { $changes[] = 'titre modifié'; }
+ if (($draftData['category'] ?? '') !== ($article['category'] ?? '')) { $changes[] = 'catégorie modifiée'; }
+ if (($draftData['tags'] ?? []) !== ($article['tags'] ?? [])) { $changes[] = 'tags modifiés'; }
+ if (($draftData['content'] ?? '') !== ($article['content'] ?? '')) { $changes[] = 'contenu modifié'; }
+ if ((bool)($draftData['published'] ?? false) !== (bool)($article['published'] ?? false)) {
+ $changes[] = ($draftData['published'] ?? false) ? 'article publié' : 'article dépublié';
+ }
+ $autoRevisionComment = !empty($changes) ? ucfirst(implode(', ', $changes)) : '';
+ $title = $draftData['title'] ?? '';
+ $seoTitle = $draftData['seo_title'] ?? '';
+ $seoDescription = $draftData['seo_description'] ?? '';
+ $published = (bool)($draftData['published'] ?? false);
+ $published_at = $draftData['published_at'] ?? '';
+ $category = $draftData['category'] ?? '';
+ include BASE_PATH . '/templates/wizard/step6.php';
+ break;
}
-
- $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;
case 'edit_tags':
@@ -1338,6 +1440,40 @@ switch ($action) {
echo json_encode(['ok' => $ok, 'time' => date('H:i:s')]);
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':
requireAuth();
header('Content-Type: application/json');
diff --git a/src/ArticleManager.php b/src/ArticleManager.php
index fcae573..1ce4397 100644
--- a/src/ArticleManager.php
+++ b/src/ArticleManager.php
@@ -228,6 +228,126 @@ class ArticleManager
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
{
if (!$this->isValidUuid($uuid)) {
diff --git a/templates/wizard/nav.php b/templates/wizard/nav.php
new file mode 100644
index 0000000..55270cc
--- /dev/null
+++ b/templates/wizard/nav.php
@@ -0,0 +1,36 @@
+
+
+
diff --git a/templates/wizard/step1.php b/templates/wizard/step1.php
new file mode 100644
index 0000000..ce11726
--- /dev/null
+++ b/templates/wizard/step1.php
@@ -0,0 +1,182 @@
+
+
+
+
+
+
+ $_f): ?>
+
+
+
+
+
+
+
+
+
+
+
+suggest($draftContent, $flatTagValues, $flatArticleTags)
+ : [];
+
+$_knownInText = array_keys(array_filter($_candidates, fn ($_c) => $_c['known']));
+$_detectedInText = array_keys(array_filter($_candidates, fn ($_c) => !$_c['known']));
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+