mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
feat: render actual elements in markdown cheatsheet instead of labels
Replace text labels (h1, bold, italic) with rendered HTML in the Rendu column: headings, strong, em, del, code, links, blockquote, lists, hr, sup, small
This commit is contained in:
20
TODO.md
20
TODO.md
@@ -1,9 +1,15 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
- [x] Create CharteController.php (public-facing controller)
|
## HTMX v2 Migration
|
||||||
- [x] Add /charte routes to Dispatcher.php
|
|
||||||
- [x] Create public/charte.php template
|
Reference: `docs/autosave-system.md` → "HTMX v2 Migration Plan" section.
|
||||||
- [x] Add 'charte' to allowed slugs in contenus.php
|
|
||||||
- [x] Add 'charte' to allowed slugs in contenus-edit.php
|
- [x] `contenus-edit.php` (pages): Add `hx-*` attrs, add `overtype:change` dispatch in OverType `onChange`
|
||||||
- [x] Add 'charte' to allowed slugs in page.php action
|
- [x] `contenus-edit.php` (form_help): Add `hx-*` attrs, add `overtype:change` dispatch in OverType `onChange`
|
||||||
- [x] Add Charte link to public header navigation (header.php)
|
- [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
|
||||||
|
|||||||
@@ -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)));
|
error_log('[apropos.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | key=' . ($_POST['apropos_key'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||||
AdminAuth::requireLogin();
|
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
|
// CSRF check
|
||||||
if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) ||
|
if (!isset($_POST['csrf_token']) || !isset($_SESSION['csrf_token']) ||
|
||||||
|
|||||||
@@ -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)));
|
error_log('[form-help.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | key=' . ($_POST['form_help_key'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||||
AdminAuth::requireLogin();
|
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'])
|
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||||
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
||||||
|
|||||||
@@ -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)));
|
error_log('[page.php] ENTRY | method=' . $_SERVER['REQUEST_METHOD'] . ' | slug=' . ($_POST['slug'] ?? 'none') . ' | post_keys=' . implode(',', array_keys($_POST)));
|
||||||
AdminAuth::requireLogin();
|
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'])
|
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|
||||||
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ $extraJsInline = '';
|
|||||||
|
|
||||||
if ($editType === 'page' || $editType === 'about_page') {
|
if ($editType === 'page' || $editType === 'about_page') {
|
||||||
$initialContent = $page["content"] ?? "";
|
$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'
|
$extraJsInline = <<<'JS'
|
||||||
var OT = window.OverType.default || window.OverType;
|
var OT = window.OverType.default || window.OverType;
|
||||||
var hidden = document.getElementById('content');
|
var hidden = document.getElementById('content');
|
||||||
@@ -92,12 +92,15 @@ var editor = new OT(document.getElementById('editor'), {
|
|||||||
minHeight: '400px',
|
minHeight: '400px',
|
||||||
spellcheck: false,
|
spellcheck: false,
|
||||||
toolbar: true,
|
toolbar: true,
|
||||||
onChange: function(value) { hidden.value = value; }
|
onChange: function(value) {
|
||||||
|
hidden.value = value;
|
||||||
|
hidden.dispatchEvent(new CustomEvent('overtype:change', { bubbles: true }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
JS;
|
JS;
|
||||||
} elseif ($editType === 'form_help') {
|
} elseif ($editType === 'form_help') {
|
||||||
$initialContent = $formHelpContent;
|
$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'
|
$extraJsInline = <<<'JS'
|
||||||
var OT = window.OverType.default || window.OverType;
|
var OT = window.OverType.default || window.OverType;
|
||||||
var hidden = document.getElementById('content');
|
var hidden = document.getElementById('content');
|
||||||
@@ -106,11 +109,14 @@ var editor = new OT(document.getElementById('editor'), {
|
|||||||
minHeight: '400px',
|
minHeight: '400px',
|
||||||
spellcheck: false,
|
spellcheck: false,
|
||||||
toolbar: true,
|
toolbar: true,
|
||||||
onChange: function(value) { hidden.value = value; }
|
onChange: function(value) {
|
||||||
|
hidden.value = value;
|
||||||
|
hidden.dispatchEvent(new CustomEvent('overtype:change', { bubbles: true }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
JS;
|
JS;
|
||||||
} elseif ($editType === 'apropos') {
|
} elseif ($editType === 'apropos') {
|
||||||
$extraJs = ["/assets/js/app/autosave.js"];
|
$extraJs = ["/assets/js/app/autosave-handler.js"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$isAdmin = true;
|
$isAdmin = true;
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
// save
|
// save
|
||||||
$content = $_POST['content'] ?? '';
|
$content = $_POST['content'] ?? '';
|
||||||
$name = trim($_POST['name'] ?? '');
|
$name = trim($_POST['name'] ?? '');
|
||||||
|
$isAutosave = ($_GET['autosave'] ?? '') === '1';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$db->setFormHelpBlock($key, $content);
|
$db->setFormHelpBlock($key, $content);
|
||||||
if ($name !== '') {
|
if ($name !== '') {
|
||||||
@@ -64,6 +66,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
$_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);
|
renderCollapsed($db, $key);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
@@ -142,9 +151,10 @@ function renderEditor(Database $db, string $key): void
|
|||||||
$content = $b['content'] ?? '';
|
$content = $b['content'] ?? '';
|
||||||
?>
|
?>
|
||||||
<div class="fhb-inline fhb-inline--editing" data-key="<?= htmlspecialchars($key) ?>">
|
<div class="fhb-inline fhb-inline--editing" data-key="<?= htmlspecialchars($key) ?>">
|
||||||
<form hx-post="/admin/form-help-inline-fragment.php?key=<?= urlencode($key) ?>"
|
<form hx-post="/admin/form-help-inline-fragment.php?key=<?= urlencode($key) ?>&autosave=1"
|
||||||
hx-swap="outerHTML"
|
hx-trigger="overtype:change delay:1500ms"
|
||||||
hx-target="closest .fhb-inline"
|
hx-swap="none"
|
||||||
|
hx-on::after-request="handleAutosaveResponse(event)"
|
||||||
class="fhb-inline-form">
|
class="fhb-inline-form">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
|
|
||||||
@@ -157,17 +167,24 @@ function renderEditor(Database $db, string $key): void
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label for="fhb-ed-<?= htmlspecialchars($key) ?>" class="fhb-edit-label">Contenu (Markdown) :</label>
|
<label for="fhb-ed-<?= htmlspecialchars($key) ?>" class="fhb-edit-label">Contenu (Markdown) :</label>
|
||||||
<label for="fhb-ed-<?= htmlspecialchars($key) ?>" class="fhb-edit-label"><a href="https://herman.bearblog.dev/markdown-cheatsheet/" target="_blank">Syntax Markdown</a></label>
|
<button type="button" class="btn btn--sm"
|
||||||
|
hx-get="/admin/markdown-cheatsheet-fragment.php"
|
||||||
|
hx-target="#md-cheatsheet-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
|
||||||
|
Aide Markdown
|
||||||
|
</button>
|
||||||
<input type="hidden" id="fhb-content-<?= htmlspecialchars($key) ?>" name="content"
|
<input type="hidden" id="fhb-content-<?= htmlspecialchars($key) ?>" name="content"
|
||||||
value="<?= htmlspecialchars($content) ?>">
|
value="<?= htmlspecialchars($content) ?>">
|
||||||
<div id="fhb-editor-<?= htmlspecialchars($key) ?>" class="fhb-overtype-editor" style="height: 40vh !important; border: 1px dashed grey"></div>
|
<div id="fhb-editor-<?= htmlspecialchars($key) ?>" class="fhb-overtype-editor" style="height: 40vh !important; border: 1px dashed grey"></div>
|
||||||
|
|
||||||
|
<div class="fhb-autosave-status" data-autosave-status></div>
|
||||||
|
|
||||||
<div class="fhb-edit-buttons">
|
<div class="fhb-edit-buttons">
|
||||||
<button type="submit" class="btn btn--primary btn--sm">Enregistrer</button>
|
|
||||||
<button type="button" class="btn btn--secondary btn--sm"
|
<button type="button" class="btn btn--secondary btn--sm"
|
||||||
hx-get="/admin/form-help-inline-fragment.php?key=<?= urlencode($key) ?>"
|
hx-get="/admin/form-help-inline-fragment.php?key=<?= urlencode($key) ?>"
|
||||||
hx-target="closest .fhb-inline"
|
hx-target="closest .fhb-inline"
|
||||||
hx-swap="outerHTML">Annuler</button>
|
hx-swap="outerHTML">Fermer</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<script>
|
<script>
|
||||||
@@ -180,7 +197,11 @@ function renderEditor(Database $db, string $key): void
|
|||||||
value: hidden.value,
|
value: hidden.value,
|
||||||
minHeight: '400px',
|
minHeight: '400px',
|
||||||
spellcheck: false,
|
spellcheck: false,
|
||||||
onChange: function(v) { hidden.value = v; }
|
toolbar: true,
|
||||||
|
onChange: function(v) {
|
||||||
|
hidden.value = v;
|
||||||
|
hidden.dispatchEvent(new CustomEvent('overtype:change', { bubbles: true }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
ed.innerHTML = '<textarea name="content" style="width:100%;min-height:400px;font-family:monospace">'
|
ed.innerHTML = '<textarea name="content" style="width:100%;min-height:400px;font-family:monospace">'
|
||||||
|
|||||||
124
app/public/admin/markdown-cheatsheet-fragment.php
Normal file
124
app/public/admin/markdown-cheatsheet-fragment.php
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Markdown cheatsheet — reusable modal dialog loaded via HTMX.
|
||||||
|
* Rendered as a <dialog> element; caller should call .showModal() after swap.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$rows = [
|
||||||
|
[
|
||||||
|
'syntax' => '# Titre 1',
|
||||||
|
'render' => '<h1>Titre 1</h1>',
|
||||||
|
'note' => 'Un # en début de ligne',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '## Titre 2',
|
||||||
|
'render' => '<h2>Titre 2</h2>',
|
||||||
|
'note' => 'Deux ## en début de ligne',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '### Titre 3',
|
||||||
|
'render' => '<h3>Titre 3</h3>',
|
||||||
|
'note' => 'Trois ### en début de ligne',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '**gras**',
|
||||||
|
'render' => '<strong>gras</strong>',
|
||||||
|
'note' => 'Double astérisque',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '*italique*',
|
||||||
|
'render' => '<em>italique</em>',
|
||||||
|
'note' => 'Simple astérisque',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '~~barré~~',
|
||||||
|
'render' => '<del>barré</del>',
|
||||||
|
'note' => 'Double tilde',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '`code`',
|
||||||
|
'render' => '<code>code</code>',
|
||||||
|
'note' => 'Backticks',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '[lien](url)',
|
||||||
|
'render' => '<a href="#" class="md-cheatsheet-link">lien</a>',
|
||||||
|
'note' => 'Texte entre crochets, URL entre parenthèses',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '',
|
||||||
|
'render' => '<span class="md-cheatsheet-img">🖼 image</span>',
|
||||||
|
'note' => 'Point d\'exclamation + même syntaxe que lien',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '> citation',
|
||||||
|
'render' => '<blockquote>citation</blockquote>',
|
||||||
|
'note' => 'Chevron > en début de ligne',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '- item',
|
||||||
|
'render' => '<ul><li>item</li></ul>',
|
||||||
|
'note' => 'Tiret + espace',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '1. item',
|
||||||
|
'render' => '<ol><li>item</li></ol>',
|
||||||
|
'note' => 'Chiffre + point + espace',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '---',
|
||||||
|
'render' => '<hr>',
|
||||||
|
'note' => 'Triple tiret = ligne horizontale',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '',
|
||||||
|
'render' => '',
|
||||||
|
'note' => '',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => 'Texte avec [^1]',
|
||||||
|
'render' => 'Texte avec <sup>1</sup>',
|
||||||
|
'note' => 'Appel de note de bas de page',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'syntax' => '[^1]: La note.',
|
||||||
|
'render' => '<small>1. La note.</small>',
|
||||||
|
'note' => 'Définition de la note (n\'importe où dans le document)',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
?>
|
||||||
|
<dialog id="md-cheatsheet-dialog" class="md-cheatsheet-dialog">
|
||||||
|
<div class="md-cheatsheet-header">
|
||||||
|
<h2>Aide Markdown</h2>
|
||||||
|
<button type="button"
|
||||||
|
class="admin-icon-btn"
|
||||||
|
onclick="this.closest('dialog').close()"
|
||||||
|
title="Fermer"
|
||||||
|
aria-label="Fermer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"></path></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="md-cheatsheet-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Syntaxe</th>
|
||||||
|
<th>Rendu</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($rows as $row): ?>
|
||||||
|
<?php if ($row['syntax'] === ''): ?>
|
||||||
|
<tr class="md-cheatsheet-separator"><td colspan="3"></td></tr>
|
||||||
|
<?php else: ?>
|
||||||
|
<tr>
|
||||||
|
<td class="md-cheatsheet-syntax"><code><?= htmlspecialchars($row['syntax']) ?></code></td>
|
||||||
|
<td class="md-cheatsheet-render"><?= $row['render'] ?></td>
|
||||||
|
<td class="md-cheatsheet-note"><?= htmlspecialchars($row['note']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</dialog>
|
||||||
@@ -2200,3 +2200,115 @@ th.admin-ap-col {
|
|||||||
color: var(--error);
|
color: var(--error);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Markdown Cheatsheet Dialog ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.md-cheatsheet-dialog {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-m, 8px);
|
||||||
|
padding: 0;
|
||||||
|
max-width: 640px;
|
||||||
|
width: 90vw;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-dialog::backdrop {
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-s) var(--space-m);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--step-0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: var(--step--1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--space-2xs) var(--space-s);
|
||||||
|
background: var(--surface2);
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-table td {
|
||||||
|
padding: var(--space-2xs) var(--space-s);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-syntax {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-syntax code {
|
||||||
|
background: var(--surface2);
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-render {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-render h1,
|
||||||
|
.md-cheatsheet-render h2,
|
||||||
|
.md-cheatsheet-render h3 {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-render h1 { font-size: var(--step-2); }
|
||||||
|
.md-cheatsheet-render h2 { font-size: var(--step-1); }
|
||||||
|
.md-cheatsheet-render h3 { font-size: var(--step-0); }
|
||||||
|
|
||||||
|
.md-cheatsheet-render blockquote {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: var(--space-2xs);
|
||||||
|
border-left: 3px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-render ul,
|
||||||
|
.md-cheatsheet-render ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-render hr {
|
||||||
|
margin: var(--space-3xs) 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-link {
|
||||||
|
color: var(--accent-blue, var(--link));
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-img {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-note {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--step--2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.md-cheatsheet-separator td {
|
||||||
|
padding: var(--space-3xs) 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|||||||
62
app/public/assets/js/app/autosave-handler.js
Normal file
62
app/public/assets/js/app/autosave-handler.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Shared HTMX after-request handler for autosave forms.
|
||||||
|
*
|
||||||
|
* Reads the JSON response, updates the CSRF token, and surfaces
|
||||||
|
* parse errors instead of silently swallowing them (unlike the
|
||||||
|
* old autosave.js .catch(() => {}) pattern).
|
||||||
|
*/
|
||||||
|
function handleAutosaveResponse(event) {
|
||||||
|
const form = event.target.closest("form");
|
||||||
|
const status = form ? form.querySelector("[data-autosave-status]") : null;
|
||||||
|
|
||||||
|
if (!event.detail.successful) {
|
||||||
|
if (status) {
|
||||||
|
status.textContent = "Erreur !";
|
||||||
|
status.className = "autosave-status autosave-status--error";
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
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.content = data.csrf_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
if (data.success) {
|
||||||
|
status.textContent = "Enregistré ✓";
|
||||||
|
status.className = "autosave-status autosave-status--saved";
|
||||||
|
} else {
|
||||||
|
status.textContent = "Erreur !";
|
||||||
|
status.className = "autosave-status autosave-status--error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// JSON parse failed (e.g. PHP warning in output) — surface it
|
||||||
|
if (status) {
|
||||||
|
status.textContent = "Erreur !";
|
||||||
|
status.className = "autosave-status autosave-status--error";
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
"Autosave: could not parse response",
|
||||||
|
event.detail.xhr.responseText,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show saving indicator while request is in flight
|
||||||
|
document.body.addEventListener("htmx:beforeRequest", (e) => {
|
||||||
|
const el = e.target;
|
||||||
|
if (!el) return;
|
||||||
|
const status = el.querySelector("[data-autosave-status]");
|
||||||
|
if (status) {
|
||||||
|
status.textContent = "Enregistrement…";
|
||||||
|
status.className = "autosave-status autosave-status--saving";
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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('', '');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -21,3 +21,8 @@
|
|||||||
{"timestamp":"2026-06-09T17:48: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":"charte"}}
|
{"timestamp":"2026-06-09T17:48: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":"charte"}}
|
||||||
{"timestamp":"2026-06-09T17:48:04+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":"charte"}}
|
{"timestamp":"2026-06-09T17:48:04+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":"charte"}}
|
||||||
{"timestamp":"2026-06-09T17:48:12+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":"charte"}}
|
{"timestamp":"2026-06-09T17:48:12+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":"charte"}}
|
||||||
|
{"timestamp":"2026-06-09T18:10:35+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":"charte"}}
|
||||||
|
{"timestamp":"2026-06-09T18:11:35+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":"charte"}}
|
||||||
|
{"timestamp":"2026-06-09T19:01: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":"licenses"}}
|
||||||
|
{"timestamp":"2026-06-09T19:12:39+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":"form_structure","action":"edit","status":"success","context":{"section":"fieldset_access"}}
|
||||||
|
{"timestamp":"2026-06-09T19:12:43+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":"form_structure","action":"edit","status":"success","context":{"section":"fieldset_access"}}
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
* $groups array Existing groups data
|
* $groups array Existing groups data
|
||||||
*/
|
*/
|
||||||
?>
|
?>
|
||||||
<form action="/admin/actions/apropos.php" method="post" class="admin-form" id="apropos-form-<?= $aproposKey ?>" data-autosave>
|
<form action="/admin/actions/apropos.php" method="post" class="admin-form" id="apropos-form-<?= $aproposKey ?>"
|
||||||
|
hx-post="/admin/actions/apropos.php"
|
||||||
|
hx-trigger="change delay:1500ms, input delay:1500ms"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="handleAutosaveResponse(event)">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
<input type="hidden" name="apropos_key" value="<?= htmlspecialchars($aproposKey) ?>">
|
<input type="hidden" name="apropos_key" value="<?= htmlspecialchars($aproposKey) ?>">
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,22 @@
|
|||||||
|
|
||||||
<!-- ── Markdown content ──────────────────────────────────────────────── -->
|
<!-- ── Markdown content ──────────────────────────────────────────────── -->
|
||||||
<h2>Contenu de la page</h2>
|
<h2>Contenu de la page</h2>
|
||||||
<form action="/admin/actions/page.php" method="post" class="admin-form" data-autosave>
|
<form action="/admin/actions/page.php" method="post" class="admin-form"
|
||||||
|
hx-post="/admin/actions/page.php"
|
||||||
|
hx-trigger="overtype:change delay:1500ms"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="handleAutosaveResponse(event)">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
|
||||||
<input type="hidden" name="slug" value="about">
|
<input type="hidden" name="slug" value="about">
|
||||||
|
|
||||||
<label for="editor">Contenu (Markdown) :</label>
|
<label for="editor">Contenu (Markdown) :</label>
|
||||||
|
<button type="button" class="btn btn--sm"
|
||||||
|
hx-get="/admin/markdown-cheatsheet-fragment.php"
|
||||||
|
hx-target="#md-cheatsheet-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
|
||||||
|
Aide Markdown
|
||||||
|
</button>
|
||||||
<input type="hidden" id="content" name="content"
|
<input type="hidden" id="content" name="content"
|
||||||
value="<?= htmlspecialchars($initialContent) ?>">
|
value="<?= htmlspecialchars($initialContent) ?>">
|
||||||
<div id="editor"></div>
|
<div id="editor"></div>
|
||||||
@@ -26,7 +37,11 @@
|
|||||||
|
|
||||||
<!-- ── Sidebar links ─────────────────────────────────────────────────── -->
|
<!-- ── Sidebar links ─────────────────────────────────────────────────── -->
|
||||||
<h2 style="margin-top:3rem;">Liens de la barre latérale</h2>
|
<h2 style="margin-top:3rem;">Liens de la barre latérale</h2>
|
||||||
<form action="/admin/actions/apropos.php" method="post" class="admin-form" id="sidebar-links-form" data-autosave>
|
<form action="/admin/actions/apropos.php" method="post" class="admin-form" id="sidebar-links-form"
|
||||||
|
hx-post="/admin/actions/apropos.php"
|
||||||
|
hx-trigger="change delay:1500ms, input delay:1500ms"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="handleAutosaveResponse(event)">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
<input type="hidden" name="apropos_key" value="sidebar_links">
|
<input type="hidden" name="apropos_key" value="sidebar_links">
|
||||||
|
|
||||||
@@ -122,11 +137,22 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<?php elseif ($editType === 'page' && $pageSlug !== 'about'): ?>
|
<?php elseif ($editType === 'page' && $pageSlug !== 'about'): ?>
|
||||||
<form action="/admin/actions/page.php" method="post" class="admin-form" data-autosave>
|
<form action="/admin/actions/page.php" method="post" class="admin-form"
|
||||||
|
hx-post="/admin/actions/page.php"
|
||||||
|
hx-trigger="overtype:change delay:1500ms"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="handleAutosaveResponse(event)">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION["csrf_token"]) ?>">
|
||||||
<input type="hidden" name="slug" value="<?= htmlspecialchars($pageSlug) ?>">
|
<input type="hidden" name="slug" value="<?= htmlspecialchars($pageSlug) ?>">
|
||||||
|
|
||||||
<label for="editor">Contenu (Markdown) :</label>
|
<label for="editor">Contenu (Markdown) :</label>
|
||||||
|
<button type="button" class="btn btn--sm"
|
||||||
|
hx-get="/admin/markdown-cheatsheet-fragment.php"
|
||||||
|
hx-target="#md-cheatsheet-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
|
||||||
|
Aide Markdown
|
||||||
|
</button>
|
||||||
<input type="hidden" id="content" name="content"
|
<input type="hidden" id="content" name="content"
|
||||||
value="<?= htmlspecialchars($initialContent) ?>">
|
value="<?= htmlspecialchars($initialContent) ?>">
|
||||||
<div id="editor"></div>
|
<div id="editor"></div>
|
||||||
@@ -135,11 +161,22 @@
|
|||||||
|
|
||||||
<?php elseif ($editType === 'form_help'): ?>
|
<?php elseif ($editType === 'form_help'): ?>
|
||||||
<p class="param-note">Ce texte est affiché dans le formulaire de soumission des étudiant·es (lien de partage). Supporte le Markdown.</p>
|
<p class="param-note">Ce texte est affiché dans le formulaire de soumission des étudiant·es (lien de partage). Supporte le Markdown.</p>
|
||||||
<form action="/admin/actions/form-help.php" method="post" class="admin-form" data-autosave>
|
<form action="/admin/actions/form-help.php" method="post" class="admin-form"
|
||||||
|
hx-post="/admin/actions/form-help.php"
|
||||||
|
hx-trigger="overtype:change delay:1500ms"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="handleAutosaveResponse(event)">
|
||||||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
||||||
<input type="hidden" name="form_help_key" value="<?= htmlspecialchars($formHelpKey) ?>">
|
<input type="hidden" name="form_help_key" value="<?= htmlspecialchars($formHelpKey) ?>">
|
||||||
|
|
||||||
<label for="editor">Contenu (Markdown) :</label>
|
<label for="editor">Contenu (Markdown) :</label>
|
||||||
|
<button type="button" class="btn btn--sm"
|
||||||
|
hx-get="/admin/markdown-cheatsheet-fragment.php"
|
||||||
|
hx-target="#md-cheatsheet-container"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-request="document.getElementById('md-cheatsheet-dialog').showModal()">
|
||||||
|
Aide Markdown
|
||||||
|
</button>
|
||||||
<input type="hidden" id="content" name="content"
|
<input type="hidden" id="content" name="content"
|
||||||
value="<?= htmlspecialchars($initialContent) ?>">
|
value="<?= htmlspecialchars($initialContent) ?>">
|
||||||
<div id="editor"></div>
|
<div id="editor"></div>
|
||||||
|
|||||||
@@ -649,5 +649,9 @@ document.addEventListener('htmx:afterSwap', function(evt) {
|
|||||||
var otScript = document.createElement('script');
|
var otScript = document.createElement('script');
|
||||||
otScript.src = '<?= App::assetV('/assets/js/vendor/overtype.min.js') ?>';
|
otScript.src = '<?= App::assetV('/assets/js/vendor/overtype.min.js') ?>';
|
||||||
document.head.appendChild(otScript);
|
document.head.appendChild(otScript);
|
||||||
|
|
||||||
|
var handlerScript = document.createElement('script');
|
||||||
|
handlerScript.src = '<?= App::assetV('/assets/js/app/autosave-handler.js') ?>';
|
||||||
|
document.head.appendChild(handlerScript);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
hx-target="#toast-region">
|
hx-target="#toast-region">
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<!-- Markdown cheatsheet container (filled by HTMX on demand) -->
|
||||||
|
<div id="md-cheatsheet-container"></div>
|
||||||
|
|
||||||
<?php foreach ($extraJs ?? [] as $js): ?>
|
<?php foreach ($extraJs ?? [] as $js): ?>
|
||||||
<script src="<?= App::assetV($js) ?>"></script>
|
<script src="<?= App::assetV($js) ?>"></script>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
@@ -30,6 +33,19 @@ document.body.addEventListener('htmx:afterSettle', function (e) {
|
|||||||
if (warn) { warn.setAttribute('tabindex', '-1'); warn.focus(); }
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -236,6 +236,156 @@ Possible causes:
|
|||||||
updates `hidden.value` programmatically. If someone adds custom JS that updates
|
updates `hidden.value` programmatically. If someone adds custom JS that updates
|
||||||
form values without firing events, autosave won't detect those changes.
|
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
|
||||||
|
<form
|
||||||
|
hx-post="/admin/actions/apropos.php"
|
||||||
|
hx-trigger="change delay:1500ms, input delay:1500ms"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="handleAutosaveResponse(event)"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="...">
|
||||||
|
<input type="hidden" name="apropos_key" value="contacts">
|
||||||
|
|
||||||
|
<!-- your inputs -->
|
||||||
|
|
||||||
|
<span data-autosave-status></span>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
One subtlety: HTMX v2 fires the trigger on the **element with `hx-trigger`**, which
|
||||||
|
here is the `<form>` — 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
|
||||||
|
<form
|
||||||
|
hx-post="/admin/actions/page.php"
|
||||||
|
hx-trigger="overtype:change delay:1500ms"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="handleAutosaveResponse(event)"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="...">
|
||||||
|
<input type="hidden" name="slug" value="charte">
|
||||||
|
<input type="hidden" id="content" name="content" value="">
|
||||||
|
|
||||||
|
<span data-autosave-status></span>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
`<script>` independent of `autosave.js`. This continues to work — HTMX reads
|
||||||
|
`FormData` at request time, so newly added/reindexed inputs are automatically
|
||||||
|
included in the next triggered save.
|
||||||
|
|
||||||
|
### Migration Checklist
|
||||||
|
|
||||||
|
| Form | Change required |
|
||||||
|
|---|---|
|
||||||
|
| `contenus-edit.php` (pages) | Add `hx-*` attrs, add `overtype:change` dispatch in OverType `onChange` |
|
||||||
|
| `contenus-edit.php` (form_help) | Same as above |
|
||||||
|
| `apropos-groups-form.php` (contacts) | Add `hx-*` attrs only |
|
||||||
|
| `contenus-edit.php` (sidebar_links) | Add `hx-*` attrs only |
|
||||||
|
| `autosave.js` | **Delete** once all four forms are migrated |
|
||||||
|
| Backend handlers | **No changes needed** — they already return `{success, csrf_token}` |
|
||||||
|
|
||||||
|
The backend is untouched throughout, which is the cleanest part of this migration.
|
||||||
|
|
||||||
## HTMX Migration Feasibility
|
## HTMX Migration Feasibility
|
||||||
|
|
||||||
The settings toggles already use HTMX successfully for similar patterns
|
The settings toggles already use HTMX successfully for similar patterns
|
||||||
|
|||||||
Reference in New Issue
Block a user