diff --git a/TODO.md b/TODO.md index 3ee7b39..f32fc35 100644 --- a/TODO.md +++ b/TODO.md @@ -23,3 +23,4 @@ - [x] Formulaire étudiant : préciser "un seul contact" dans le label, mise à jour du hint pour le format le plus court (site sans https://www., insta/mastodon avec @), adaptation de l'affichage public pour supporter ces formats courts (liens automatiques pour @pseudo → Instagram, @pseudo@instance → Mastodon, domaine nu → https://) - [x] Déplacer "Contact visible" du Backoffice vers "Informations du TFE" dans le formulaire admin edit, renommer "Identité" → "Informations du TFE" dans le récapitulatif admin +- [x] Rework contenus-edit: auto-save (debounce 1.5s) sur tous les formulaires (page, form-help, contacts, sidebar links), toolbar OverType sur tous les éditeurs markdown, sidebar links dynamiques (add/remove) remplaçant les 2 liens fixes erg_site_url/source_code_url par un seul key sidebar_links avec fallback de migration diff --git a/app/public/admin/actions/apropos.php b/app/public/admin/actions/apropos.php index 5b00c10..3a5122e 100644 --- a/app/public/admin/actions/apropos.php +++ b/app/public/admin/actions/apropos.php @@ -1,25 +1,45 @@ 'Erreur de sécurité : token invalide.']); + } else { + die("Erreur de sécurité : token invalide."); + } + exit; } $urlKeys = ['erg_site_url', 'source_code_url']; $groupKeys = ['contacts']; -$allowedKeys = array_merge($urlKeys, $groupKeys); +$linkListKeys = ['sidebar_links']; +$allowedKeys = array_merge($urlKeys, $groupKeys, $linkListKeys); $aproposKey = $_POST['apropos_key'] ?? ''; if (!in_array($aproposKey, $allowedKeys)) { - die("Clé invalide."); + if ($isAjax) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Clé invalide.']); + } else { + die("Clé invalide."); + } + exit; } require_once __DIR__ . '/../../../src/Database.php'; @@ -30,12 +50,40 @@ try { $db = new Database(); if (in_array($aproposKey, $urlKeys)) { - // ── URL-based keys (sidebar links) ── + // ── URL-based keys (legacy sidebar links) ── $url = trim($_POST['url'] ?? ''); if ($url !== '' && !filter_var($url, FILTER_VALIDATE_URL)) { - die("URL invalide."); + if ($isAjax) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(['error' => 'URL invalide.']); + } else { + die("URL invalide."); + } + exit; } $db->saveAproposContent($aproposKey, $url); + } elseif (in_array($aproposKey, $linkListKeys)) { + // ── Sidebar links list ── + $links = $_POST['links'] ?? []; + $cleaned = []; + foreach ($links as $link) { + $label = trim($link['label'] ?? ''); + $url = trim($link['url'] ?? ''); + if ($label === '') continue; + if ($url !== '' && !filter_var($url, FILTER_VALIDATE_URL)) { + if ($isAjax) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(['error' => 'URL invalide: ' . htmlspecialchars($label)]); + } else { + die("URL invalide: " . htmlspecialchars($label)); + } + exit; + } + $cleaned[] = ['label' => $label, 'url' => $url]; + } + $db->saveAproposContent($aproposKey, $cleaned); } else { // ── Group-based keys (contacts) ── $groups = $_POST['groups'] ?? []; @@ -61,18 +109,43 @@ try { } if (empty($cleaned)) { - die("Au moins un groupe avec des entrées est requis."); + if ($isAjax) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Au moins un groupe avec des entrées est requis.']); + } else { + die("Au moins un groupe avec des entrées est requis."); + } + exit; } $db->saveAproposContent($aproposKey, $cleaned); } AdminLogger::make()->logAproposEdit($aproposKey); - App::flash('success', "Contenu « $aproposKey » mis à jour avec succès."); + + if ($isAjax) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'csrf_token' => $_SESSION['csrf_token'], + ]); + } else { + App::flash('success', "Contenu « $aproposKey » mis à jour avec succès."); + header('Location: /admin/contenus.php'); + } } catch (Exception $e) { ErrorHandler::log('apropos', $e); - die('Erreur lors de la sauvegarde : ' . htmlspecialchars(ErrorHandler::userMessage($e))); + $msg = 'Erreur lors de la sauvegarde : ' . htmlspecialchars(ErrorHandler::userMessage($e)); + if ($isAjax) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode(['error' => $msg]); + } else { + die($msg); + } + exit; } -header('Location: /admin/contenus.php'); exit; diff --git a/app/public/admin/actions/form-help.php b/app/public/admin/actions/form-help.php index 025096c..058656a 100644 --- a/app/public/admin/actions/form-help.php +++ b/app/public/admin/actions/form-help.php @@ -2,14 +2,23 @@ /** * Save handler for form help blocks (student-facing explanatory text shown * in the /partage share-link submission form). + * Supports both regular form POST and AJAX auto-save requests. */ require_once __DIR__ . '/../../../bootstrap.php'; require_once __DIR__ . '/../../../src/AdminAuth.php'; error_log('[form-help.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | key=' . ($_POST['form_help_key'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST))); AdminAuth::requireLogin(); +$isAjax = !empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'); + if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { + if ($isAjax) { + http_response_code(403); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Erreur de sécurité : token invalide.']); + exit; + } App::flash('error', 'Erreur de sécurité : token invalide.'); header('Location: /admin/contenus.php'); exit; @@ -24,6 +33,12 @@ require_once APP_ROOT . '/src/ErrorHandler.php'; $db = new Database(); if (!in_array($key, Database::FORM_HELP_KEYS, true)) { + if ($isAjax) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Clé de bloc invalide.']); + exit; + } App::flash('error', 'Clé de bloc invalide.'); header('Location: /admin/contenus.php'); exit; @@ -32,10 +47,27 @@ if (!in_array($key, Database::FORM_HELP_KEYS, true)) { try { $db->setFormHelpBlock($key, $content); AdminLogger::make()->logFormStructureEdit($key); + + if ($isAjax) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'csrf_token' => $_SESSION['csrf_token'], + ]); + exit; + } App::flash('success', 'Bloc « ' . htmlspecialchars($key) . ' » mis à jour.'); } catch (Exception $e) { ErrorHandler::log('form_help', $e); - App::flash('error', 'Erreur lors de la sauvegarde : ' . ErrorHandler::userMessage($e)); + $msg = 'Erreur lors de la sauvegarde : ' . ErrorHandler::userMessage($e); + if ($isAjax) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode(['error' => $msg]); + exit; + } + App::flash('error', $msg); } $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); diff --git a/app/public/admin/actions/page.php b/app/public/admin/actions/page.php index 24370b1..20595eb 100644 --- a/app/public/admin/actions/page.php +++ b/app/public/admin/actions/page.php @@ -1,14 +1,23 @@ 'Erreur de sécurité : token invalide.']); + exit; + } App::flash('error', 'Erreur de sécurité : token invalide.'); header('Location: /admin/contenus.php'); exit; @@ -19,6 +28,12 @@ $slug = $_POST['slug'] ?? ''; $content = $_POST['content'] ?? ''; if (!in_array($slug, $allowedSlugs, true)) { + if ($isAjax) { + http_response_code(400); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Slug de page invalide.']); + exit; + } App::flash('error', 'Slug de page invalide.'); header('Location: /admin/contenus.php'); exit; @@ -32,10 +47,27 @@ $db = new Database(); try { $db->savePage($slug, $content); AdminLogger::make()->logPageEdit($slug); + + if ($isAjax) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + header('Content-Type: application/json'); + echo json_encode([ + 'success' => true, + 'csrf_token' => $_SESSION['csrf_token'], + ]); + exit; + } App::flash('success', 'Page « ' . htmlspecialchars($slug) . ' » mise à jour.'); } catch (Exception $e) { ErrorHandler::log('page', $e); - App::flash('error', 'Erreur lors de la sauvegarde : ' . ErrorHandler::userMessage($e)); + $msg = 'Erreur lors de la sauvegarde : ' . ErrorHandler::userMessage($e); + if ($isAjax) { + http_response_code(500); + header('Content-Type: application/json'); + echo json_encode(['error' => $msg]); + exit; + } + App::flash('error', $msg); } $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); diff --git a/app/public/admin/contenus-edit.php b/app/public/admin/contenus-edit.php index efac644..ea6497e 100644 --- a/app/public/admin/contenus-edit.php +++ b/app/public/admin/contenus-edit.php @@ -44,8 +44,19 @@ try { $editType = 'about_page'; $aboutContacts = $db->getAproposContent('contacts'); $aboutContacts = is_array($aboutContacts) ? $aboutContacts : []; - $ergSiteUrl = $db->getAproposContent('erg_site_url'); - $sourceCodeUrl = $db->getAproposContent('source_code_url'); + $sidebarLinks = $db->getAproposContent('sidebar_links'); + $sidebarLinks = is_array($sidebarLinks) ? $sidebarLinks : []; + // Fallback: migrate legacy individual sidebar link keys + if (empty($sidebarLinks)) { + $ergSiteUrl = $db->getAproposContent('erg_site_url'); + $sourceCodeUrl = $db->getAproposContent('source_code_url'); + if ($ergSiteUrl) { + $sidebarLinks[] = ['label' => 'Site de l\'erg', 'url' => $ergSiteUrl]; + } + if ($sourceCodeUrl) { + $sidebarLinks[] = ['label' => 'Code source', 'url' => $sourceCodeUrl]; + } + } } else { $editType = "page"; } @@ -72,7 +83,7 @@ $extraJsInline = ''; if ($editType === 'page' || $editType === 'about_page') { $initialContent = $page["content"] ?? ""; - $extraJs = ["/assets/js/vendor/overtype.min.js"]; + $extraJs = ["/assets/js/vendor/overtype.min.js", "/assets/js/app/autosave.js"]; $extraJsInline = <<<'JS' var OT = window.OverType.default || window.OverType; var hidden = document.getElementById('content'); @@ -80,12 +91,13 @@ var editor = new OT(document.getElementById('editor'), { value: hidden.value, minHeight: '400px', spellcheck: false, + toolbar: true, onChange: function(value) { hidden.value = value; } }); JS; } elseif ($editType === 'form_help') { $initialContent = $formHelpContent; - $extraJs = ["/assets/js/vendor/overtype.min.js"]; + $extraJs = ["/assets/js/vendor/overtype.min.js", "/assets/js/app/autosave.js"]; $extraJsInline = <<<'JS' var OT = window.OverType.default || window.OverType; var hidden = document.getElementById('content'); @@ -93,9 +105,12 @@ var editor = new OT(document.getElementById('editor'), { value: hidden.value, minHeight: '400px', spellcheck: false, + toolbar: true, onChange: function(value) { hidden.value = value; } }); JS; +} elseif ($editType === 'apropos') { + $extraJs = ["/assets/js/app/autosave.js"]; } $isAdmin = true; diff --git a/app/public/assets/css/admin.css b/app/public/assets/css/admin.css index 7860d52..96283b4 100644 --- a/app/public/assets/css/admin.css +++ b/app/public/assets/css/admin.css @@ -2140,3 +2140,63 @@ th.admin-ap-col { width: 100%; box-sizing: border-box; } + +/* ── Sidebar links editor ──────────────────────────────────────────────── */ + +.sidebar-link-row { + display: flex; + align-items: flex-start; + gap: var(--space-xs); + margin-bottom: var(--space-s); +} + +.sidebar-link-fields { + display: grid; + grid-template-columns: 1fr 2fr; + gap: var(--space-s); + flex: 1; +} + +.sidebar-link-fields > div { + display: flex; + flex-direction: column; +} + +.sidebar-link-fields label { + font-size: var(--step--1); + margin-bottom: var(--space-3xs); +} + +.sidebar-link-fields input { + width: 100%; + box-sizing: border-box; +} + +.sidebar-link-row .admin-icon-btn--delete { + margin-top: calc(var(--step--1) + var(--space-3xs) + 0.3em); + flex-shrink: 0; +} + +/* ── Auto-save status indicator ────────────────────────────────────────── */ + +.autosave-status { + font-size: var(--step--2); + min-height: 1.5em; + margin-top: var(--space-2xs); + color: var(--text-tertiary); + transition: color 0.2s; +} + +.autosave-status--saving { + color: var(--accent-yellow); + font-style: italic; +} + +.autosave-status--saved { + color: var(--accent-green); +} + +.autosave-status--error { + color: var(--error); + font-weight: 500; +} diff --git a/app/public/assets/css/apropos.css b/app/public/assets/css/apropos.css index 7b62ba8..785efea 100644 --- a/app/public/assets/css/apropos.css +++ b/app/public/assets/css/apropos.css @@ -71,35 +71,23 @@ border-left-color: var(--accent-primary); } -.apropos-toc-erg { +.apropos-toc-link:first-of-type { padding-top: var(--space-s); border-top: 1px solid var(--border-primary); } -.apropos-toc-erg a { - font-size: var(--step--2); - color: var(--accent-primary); - text-decoration: none; - transition: opacity 0.15s; -} - -.apropos-toc-erg a:hover { - color: var(--accent-primary); - opacity: 1; -} - -.apropos-toc-source { +.apropos-toc-link + .apropos-toc-link { padding-top: var(--space-xs); } -.apropos-toc-source a { +.apropos-toc-link a { font-size: var(--step--2); color: var(--accent-primary); text-decoration: none; transition: opacity 0.15s; } -.apropos-toc-source a:hover { +.apropos-toc-link a:hover { color: var(--accent-primary); opacity: 1; } @@ -326,7 +314,7 @@ padding-left: 0; } - .apropos-toc-erg { + .apropos-toc-link { border-top: none; padding-top: 0; margin-left: auto; diff --git a/app/public/assets/js/app/autosave.js b/app/public/assets/js/app/autosave.js new file mode 100644 index 0000000..3ba9244 --- /dev/null +++ b/app/public/assets/js/app/autosave.js @@ -0,0 +1,76 @@ +/** + * Auto-save for admin content edit forms. + * + * Watches forms with [data-autosave] attribute. Debounces 1.5s after the + * last change, POSTs to the form's action, and shows a small status bar. + * + * The status indicator lives in a sibling element with [data-autosave-status]. + * States: idle → saving… → saved ✓ / error ! + */ +(() => { + const forms = document.querySelectorAll('form[data-autosave]'); + if (!forms.length) return; + + const DEBOUNCE_MS = 1500; + + for (const form of forms) { + const statusEl = document.querySelector(form.dataset.autosaveStatus || '[data-autosave-status]'); + let timer = null; + let dirty = false; + + const setStatus = (text, cls) => { + if (!statusEl) return; + statusEl.textContent = text; + statusEl.className = 'autosave-status ' + (cls || ''); + }; + + const doSave = () => { + if (!dirty) return; + dirty = false; + setStatus('Enregistrement…', 'autosave-status--saving'); + + const formData = new FormData(form); + + fetch(form.action, { + method: 'POST', + body: formData, + headers: { 'Accept': 'application/json' }, + }) + .then((r) => { + if (!r.ok) throw new Error('HTTP ' + r.status); + setStatus('Enregistré ✓', 'autosave-status--saved'); + // Refresh CSRF token from response if provided + r.json().then((data) => { + if (data && data.csrf_token) { + const csrfInput = form.querySelector('input[name="csrf_token"]'); + if (csrfInput) csrfInput.value = data.csrf_token; + const meta = document.querySelector('meta[name="csrf-token"]'); + if (meta) meta.setAttribute('content', data.csrf_token); + } + }).catch(() => {}); + }) + .catch(() => { + setStatus('Erreur !', 'autosave-status--error'); + dirty = true; + }); + }; + + const schedule = () => { + dirty = true; + setStatus('', ''); + clearTimeout(timer); + timer = setTimeout(doSave, DEBOUNCE_MS); + }; + + // Watch all inputs inside the form + form.addEventListener('input', schedule); + form.addEventListener('change', schedule); + + // Clear dirty on manual submit + form.addEventListener('submit', () => { + clearTimeout(timer); + dirty = false; + setStatus('', ''); + }); + } +})(); diff --git a/app/src/Controllers/AboutController.php b/app/src/Controllers/AboutController.php index cdb8cf2..64916bf 100644 --- a/app/src/Controllers/AboutController.php +++ b/app/src/Controllers/AboutController.php @@ -24,16 +24,26 @@ class AboutController if (empty(trim($rawContent)) || trim($rawContent) === 'Contenu à venir') { $rawContent = $this->defaultContent; } - $contacts = $db->getAproposContent('contacts'); - $contacts = is_array($contacts) && !empty($contacts) ? $contacts : null; - $ergSiteUrl = $db->getAproposContent('erg_site_url'); - $sourceCodeUrl = $db->getAproposContent('source_code_url'); + $contacts = $db->getAproposContent('contacts'); + $contacts = is_array($contacts) && !empty($contacts) ? $contacts : null; + $sidebarLinks = $db->getAproposContent('sidebar_links'); + $sidebarLinks = is_array($sidebarLinks) ? $sidebarLinks : []; + // Fallback: migrate legacy individual sidebar link keys + if (empty($sidebarLinks)) { + $ergSiteUrl = $db->getAproposContent('erg_site_url'); + $sourceCodeUrl = $db->getAproposContent('source_code_url'); + if ($ergSiteUrl) { + $sidebarLinks[] = ['label' => 'Site de l\'erg', 'url' => $ergSiteUrl]; + } + if ($sourceCodeUrl) { + $sidebarLinks[] = ['label' => 'Code source', 'url' => $sourceCodeUrl]; + } + } } catch (Exception $e) { ErrorHandler::log('about_page', $e); - $rawContent = $this->defaultContent; - $contacts = null; - $ergSiteUrl = null; - $sourceCodeUrl = null; + $rawContent = $this->defaultContent; + $contacts = null; + $sidebarLinks = []; } $converter = new CommonMarkConverter(['html_input' => 'strip']); @@ -42,8 +52,7 @@ class AboutController 'currentNav' => 'apropos', 'aboutHtml' => EmailObfuscator::obfuscateHtml($converter->convert($rawContent)->getContent()), 'contacts' => $contacts, - 'ergSiteUrl' => $ergSiteUrl, - 'sourceCodeUrl' => $sourceCodeUrl, + 'sidebarLinks' => $sidebarLinks, 'pageTitle' => 'À Propos – XAMXAM', 'metaDescription' => "À propos de XAMXAM, le répertoire des mémoires de fin d'études de l'erg – École de Recherches Graphiques de Bruxelles.", 'extraCss' => ['/assets/css/apropos.css'], diff --git a/app/storage/logs/admin-2026-06-09.log b/app/storage/logs/admin-2026-06-09.log index 52c4c77..cc3c294 100644 --- a/app/storage/logs/admin-2026-06-09.log +++ b/app/storage/logs/admin-2026-06-09.log @@ -7,3 +7,10 @@ {"timestamp":"2026-06-09T10:34:52+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"thesis","action":"edit","status":"success","context":{"thesis_id":26,"title":"DepNum"}} {"timestamp":"2026-06-09T10:42:57+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"thesis","action":"edit","status":"success","context":{"thesis_id":26,"title":"DepNum"}} {"timestamp":"2026-06-09T12:10:41+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"thesis","action":"edit","status":"success","context":{"thesis_id":26,"title":"DepNum"}} +{"timestamp":"2026-06-09T15:04:54+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"apropos","action":"edit","status":"success","context":{"key":"contacts"}} +{"timestamp":"2026-06-09T15:15:33+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"page","action":"edit","status":"success","context":{"slug":"about"}} +{"timestamp":"2026-06-09T15:15:34+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"page","action":"edit","status":"success","context":{"slug":"about"}} +{"timestamp":"2026-06-09T15:19:00+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"page","action":"edit","status":"success","context":{"slug":"about"}} +{"timestamp":"2026-06-09T15:19:03+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"page","action":"edit","status":"success","context":{"slug":"about"}} +{"timestamp":"2026-06-09T15:20:57+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"apropos","action":"edit","status":"success","context":{"key":"contacts"}} +{"timestamp":"2026-06-09T15:21:03+00:00","ip":"127.0.0.1","user_agent":"Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0","resource":"apropos","action":"edit","status":"success","context":{"key":"contacts"}} diff --git a/app/templates/admin/apropos-groups-form.php b/app/templates/admin/apropos-groups-form.php index ba52fda..76ba50c 100644 --- a/app/templates/admin/apropos-groups-form.php +++ b/app/templates/admin/apropos-groups-form.php @@ -6,7 +6,7 @@ * $groups array Existing groups data */ ?> -