diff --git a/TODO.md b/TODO.md index 150550e..b05d0b9 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,15 @@ # TODO -- [x] Create CharteController.php (public-facing controller) -- [x] Add /charte routes to Dispatcher.php -- [x] Create public/charte.php template -- [x] Add 'charte' to allowed slugs in contenus.php -- [x] Add 'charte' to allowed slugs in contenus-edit.php -- [x] Add 'charte' to allowed slugs in page.php action -- [x] Add Charte link to public header navigation (header.php) +## HTMX v2 Migration + +Reference: `docs/autosave-system.md` → "HTMX v2 Migration Plan" section. + +- [x] `contenus-edit.php` (pages): Add `hx-*` attrs, add `overtype:change` dispatch in OverType `onChange` +- [x] `contenus-edit.php` (form_help): Add `hx-*` attrs, add `overtype:change` dispatch in OverType `onChange` +- [x] `apropos-groups-form.php` (contacts): Add `hx-*` attrs only +- [x] `contenus-edit.php` (sidebar_links): Add `hx-*` attrs only +- [x] Add `handleAutosaveResponse()` shared handler + `htmx:beforeRequest` loading state +- [x] Delete `autosave.js` +- [x] Fix backend `$isAjax` detection: also recognize `HX-Request` header (page.php, apropos.php, form-help.php) +- [x] Form-help inline editors: add OverType toolbar + HTMX auto-save + remove save buttons +- [x] Markdown cheatsheet modal: reusable dialog on all OverType editors diff --git a/app/public/admin/actions/apropos.php b/app/public/admin/actions/apropos.php index 3a5122e..08f4b32 100644 --- a/app/public/admin/actions/apropos.php +++ b/app/public/admin/actions/apropos.php @@ -11,7 +11,8 @@ require_once __DIR__ . '/../../../src/AdminAuth.php'; error_log('[apropos.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | key=' . ($_POST['apropos_key'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST))); AdminAuth::requireLogin(); -$isAjax = !empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'); +$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) + || !empty($_SERVER['HTTP_HX_REQUEST']); // CSRF check if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) || diff --git a/app/public/admin/actions/form-help.php b/app/public/admin/actions/form-help.php index 058656a..98d6e00 100644 --- a/app/public/admin/actions/form-help.php +++ b/app/public/admin/actions/form-help.php @@ -9,7 +9,8 @@ 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'); +$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) + || !empty($_SERVER['HTTP_HX_REQUEST']); if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { diff --git a/app/public/admin/actions/page.php b/app/public/admin/actions/page.php index ad4a6cb..af35480 100644 --- a/app/public/admin/actions/page.php +++ b/app/public/admin/actions/page.php @@ -8,7 +8,8 @@ require_once __DIR__ . '/../../../src/AdminAuth.php'; error_log('[page.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | slug=' . ($_POST['slug'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST))); AdminAuth::requireLogin(); -$isAjax = !empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json'); +$isAjax = (!empty($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) + || !empty($_SERVER['HTTP_HX_REQUEST']); if (!isset($_POST['csrf_token'], $_SESSION['csrf_token']) || !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) { diff --git a/app/public/admin/contenus-edit.php b/app/public/admin/contenus-edit.php index 7f9467b..feba936 100644 --- a/app/public/admin/contenus-edit.php +++ b/app/public/admin/contenus-edit.php @@ -83,7 +83,7 @@ $extraJsInline = ''; if ($editType === 'page' || $editType === 'about_page') { $initialContent = $page["content"] ?? ""; - $extraJs = ["/assets/js/vendor/overtype.min.js", "/assets/js/app/autosave.js"]; + $extraJs = ["/assets/js/vendor/overtype.min.js", "/assets/js/app/autosave-handler.js"]; $extraJsInline = <<<'JS' var OT = window.OverType.default || window.OverType; var hidden = document.getElementById('content'); @@ -92,12 +92,15 @@ var editor = new OT(document.getElementById('editor'), { minHeight: '400px', spellcheck: false, toolbar: true, - onChange: function(value) { hidden.value = value; } + onChange: function(value) { + hidden.value = value; + hidden.dispatchEvent(new CustomEvent('overtype:change', { bubbles: true })); + } }); JS; } elseif ($editType === 'form_help') { $initialContent = $formHelpContent; - $extraJs = ["/assets/js/vendor/overtype.min.js", "/assets/js/app/autosave.js"]; + $extraJs = ["/assets/js/vendor/overtype.min.js", "/assets/js/app/autosave-handler.js"]; $extraJsInline = <<<'JS' var OT = window.OverType.default || window.OverType; var hidden = document.getElementById('content'); @@ -106,11 +109,14 @@ var editor = new OT(document.getElementById('editor'), { minHeight: '400px', spellcheck: false, toolbar: true, - onChange: function(value) { hidden.value = value; } + onChange: function(value) { + hidden.value = value; + hidden.dispatchEvent(new CustomEvent('overtype:change', { bubbles: true })); + } }); JS; } elseif ($editType === 'apropos') { - $extraJs = ["/assets/js/app/autosave.js"]; + $extraJs = ["/assets/js/app/autosave-handler.js"]; } $isAdmin = true; diff --git a/app/public/admin/form-help-inline-fragment.php b/app/public/admin/form-help-inline-fragment.php index cd7b154..5603ef5 100644 --- a/app/public/admin/form-help-inline-fragment.php +++ b/app/public/admin/form-help-inline-fragment.php @@ -49,6 +49,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { // save $content = $_POST['content'] ?? ''; $name = trim($_POST['name'] ?? ''); + $isAutosave = ($_GET['autosave'] ?? '') === '1'; + try { $db->setFormHelpBlock($key, $content); if ($name !== '') { @@ -64,6 +66,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + + if ($isAutosave) { + header('Content-Type: application/json'); + echo json_encode(['success' => true, 'csrf_token' => $_SESSION['csrf_token']]); + exit; + } + renderCollapsed($db, $key); exit; } @@ -142,9 +151,10 @@ function renderEditor(Database $db, string $key): void $content = $b['content'] ?? ''; ?>
-
@@ -157,17 +167,24 @@ function renderEditor(Database $db, string $key): void
- +
+
+
- + hx-swap="outerHTML">Fermer
-
+ "> +
@@ -135,11 +161,22 @@

Ce texte est affiché dans le formulaire de soumission des étudiant·es (lien de partage). Supporte le Markdown.

- + +
diff --git a/app/templates/admin/contenus.php b/app/templates/admin/contenus.php index ed2c364..c535f3f 100644 --- a/app/templates/admin/contenus.php +++ b/app/templates/admin/contenus.php @@ -649,5 +649,9 @@ document.addEventListener('htmx:afterSwap', function(evt) { var otScript = document.createElement('script'); otScript.src = ''; document.head.appendChild(otScript); + + var handlerScript = document.createElement('script'); + handlerScript.src = ''; + document.head.appendChild(handlerScript); })(); diff --git a/app/templates/admin/footer.php b/app/templates/admin/footer.php index f19f5f0..cedf9b5 100644 --- a/app/templates/admin/footer.php +++ b/app/templates/admin/footer.php @@ -7,6 +7,9 @@ hx-target="#toast-region"> + +
+ @@ -30,6 +33,19 @@ document.body.addEventListener('htmx:afterSettle', function (e) { if (warn) { warn.setAttribute('tabindex', '-1'); warn.focus(); } } }); + +// Markdown cheatsheet: close on backdrop click, remove stale dialogs before new one arrives +document.body.addEventListener('htmx:beforeRequest', function(e) { + if (e.detail.requestConfig && e.detail.requestConfig.path === '/admin/markdown-cheatsheet-fragment.php') { + var old = document.getElementById('md-cheatsheet-dialog'); + if (old) old.remove(); + } +}); +document.body.addEventListener('click', function(e) { + if (e.target.tagName === 'DIALOG' && e.target.id === 'md-cheatsheet-dialog') { + e.target.close(); + } +}); diff --git a/docs/autosave-system.md b/docs/autosave-system.md index 8c2afb7..856ca40 100644 --- a/docs/autosave-system.md +++ b/docs/autosave-system.md @@ -236,6 +236,156 @@ Possible causes: updates `hidden.value` programmatically. If someone adds custom JS that updates form values without firing events, autosave won't detect those changes. +## HTMX v2 Migration Plan + +### The Core Pattern + +HTMX v2 replaces the entire `autosave.js` fetch/debounce/CSRF-update loop. The key pieces: + +- `hx-trigger` handles debouncing +- `hx-on::after-request` handles CSRF token rotation +- `hx-swap="none"` since you only need the JSON response side-effect +- A response header (`HX-Trigger`) can drive the status indicator + +### 1. Native Input Forms (Contacts & Sidebar Links) — Full Replacement + +These are straightforward since all inputs fire native DOM events. + +```html + + + + + + + +
+``` + +One subtlety: HTMX v2 fires the trigger on the **element with `hx-trigger`**, which +here is the `
` — so `change` and `input` events bubbling up from child inputs +will correctly trigger it. + +### 2. OverType Forms (Static Pages & Form Help) — Partial Replacement + +OverType updates the hidden input programmatically without firing DOM events, so you +need to dispatch a custom event from its `onChange` hook: + +```js +// In your OverType init +onChange: function(value) { + hiddenInput.value = value; + // Dispatch a custom event that HTMX can listen for + hiddenInput.dispatchEvent( + new CustomEvent('overtype:change', { bubbles: true }) + ); +} +``` + +Then on the form: + +```html + + + + + + +
+``` + +### 3. CSRF Rotation + Status Indicator + +Replace `autosave.js`'s `.json().then()` pattern with a single shared handler. The +silent-parse-error risk disappears because you're explicitly handling the response: + +```js +function handleAutosaveResponse(event) { + const status = event.target.closest('form') + .querySelector('[data-autosave-status]'); + + if (!event.detail.successful) { + if (status) status.textContent = 'Erreur !'; + return; + } + + try { + const data = JSON.parse(event.detail.xhr.responseText); + + // Rotate CSRF token in both the form and the meta tag + if (data.csrf_token) { + event.target.closest('form') + .querySelector('input[name="csrf_token"]').value = data.csrf_token; + const meta = document.querySelector('meta[name="csrf-token"]'); + if (meta) meta.content = data.csrf_token; + } + + if (status) status.textContent = data.success ? 'Enregistré ✓' : 'Erreur !'; + + } catch { + // JSON parse failed (e.g. PHP warning in output) — surface it rather than silently swallowing + if (status) status.textContent = 'Erreur !'; + console.warn('Autosave: could not parse response', event.detail.xhr.responseText); + } +} +``` + +This is a direct improvement over the current `autosave.js` `.catch(() => {})` +silent swallow — you now see the PHP warning in the console instead of just +getting a mystery 403 on the next save. + +### 4. "Loading" State During Save + +If you want a saving indicator (the current `"Enregistrement…"` state), use +`htmx:beforeRequest` on the form — or use HTMX's built-in `htmx-request` class +which is added to the element automatically while a request is in flight: + +```css +/* Target the class HTMX adds */ +form.htmx-request [data-autosave-status]::after { + content: 'Enregistrement…'; +} +``` + +Or explicitly with an event listener: + +```js +document.body.addEventListener('htmx:beforeRequest', e => { + const status = e.target.querySelector('[data-autosave-status]'); + if (status) status.textContent = 'Enregistrement…'; +}); +``` + +### 5. The Add/Remove Group JS Still Works Unchanged + +The reindex logic (updating `name` attributes after add/remove) is inline +`