Add Charte static page (public + admin editing)

This commit is contained in:
Pontoporeia
2026-06-09 19:43:09 +02:00
parent 317547ac93
commit 4a2b000fca
10 changed files with 405 additions and 30 deletions

34
TODO.md
View File

@@ -1,29 +1,9 @@
# TODO # TODO
- [x] Fix #1: TFE publié se dépublie quand on modifie ses données (is_published missing from getThesisRawFields SELECT) - [x] Create CharteController.php (public-facing controller)
- [x] Fix #2: Renommer "Note contextuelle" → "Note contextuelle relative à soutenance" - [x] Add /charte routes to Dispatcher.php
- [x] Fix #3: Impossible de mettre une majuscule au nom d'étudiant·e — la recherche par nom en SQLite est case-sensitive (BINARY), contournait le UPDATE et tombait dans le fallback email sans updater le nom. Ajout COLLATE NOCASE + UPDATE dans le chemin email. - [x] Create public/charte.php template
- [x] Fix #4: Décorréler contact interne et contact visible (ajouter colonne contact_visible sur theses) - [x] Add 'charte' to allowed slugs in contenus.php
- [x] Fix #4 (v2): Découplage complet dans ThesisCreateController — validateAndSanitise ne croise plus contact_interne ↔ mail/contact_visible, submit utilise contactInterne (et non mail) comme email de l'auteurice - [x] Add 'charte' to allowed slugs in contenus-edit.php
- [x] Fix #5: "Contact public : non" partout, non modifiable, sans impact - [x] Add 'charte' to allowed slugs in page.php action
- [x] Fix #6: Investiguer "libre → interne" impossible — aucune restriction trouvée dans le code admin - [x] Add Charte link to public header navigation (header.php)
- [x] Hotfix: contact_visible manquant dans le SQL de updateThesis (l'edit matchait createThesis à la place)
- [x] Fix #7: Options de licence non persistées en edit — HTMX load trigger perdait les valeurs
- [x] Fix #3 (v2): findOrCreateAuthor avec cascade ID → nom → email, setThesisAuthors passe les IDs existants
- [x] Migration 038: corriger les identifiers theses qui ne matchent pas leur année
- [x] Filtres finalité + format dans la page de recherche (search.php)
- [x] Styliser boutons Filtrer/Réinitialiser : plus compacts, Réinitialiser en neutre
- [x] Commit + jj new
- [x] Fix identifier-year mismatch: extend save() to regenerate identifier when prefix doesn't match year (not just on year change)
- [x] Fix migration runner run.php to support .php migrations alongside .sql
- [x] Fix runner: treat PHP subprocess idempotent errors (no such column / already exists) as skippable rather than fatal
- [x] Update 016 and 038 PHP migrations to accept $argv[1] DB path
- [x] Fix migration 016 to gracefully handle banner_path column already being dropped
- [x] Commit + jj new
- [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
- [x] Fix FilePond "Fichier trop volumineux Taille max: 1byte" — le plugin FileValidateSize surcharge le setter core de maxFileSize et parse la string "1GB" via toInt → 1 au lieu de toBytes → 1073741824. Passage de toutes les valeurs maxFileSize et perExtensionMaxSize en nombres bruts (bytes). Correction du nom d'option fileValidateSizeFilterItem → fileValidateSizeFilter. Adaptation de parseSize pour accepter les nombres.
- [x] Fix FilePond: fichiers perdus après reload — les uploads temporaires (tmp/filepond/) disparaissaient car data-existing-files ne contenait que les fichiers en DB. Ajout tracking session ($_SESSION['filepond_tmp']) dans handleProcess, injection des fichiers temporaires de la session dans data-existing-files via getSessionTempFiles(), loadTempFile() dans handleLoad pour streamer depuis tmp/, et routage remove → revert pour les hex IDs.

View File

@@ -23,7 +23,7 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
exit; exit;
} }
$allowedSlugs = ['about', 'licenses']; $allowedSlugs = ['about', 'licenses', 'charte'];
$slug = $_POST['slug'] ?? ''; $slug = $_POST['slug'] ?? '';
$content = $_POST['content'] ?? ''; $content = $_POST['content'] ?? '';

View File

@@ -9,7 +9,7 @@ if (empty($_SESSION["csrf_token"])) {
$_SESSION["csrf_token"] = bin2hex(random_bytes(32)); $_SESSION["csrf_token"] = bin2hex(random_bytes(32));
} }
$allowedPageSlugs = ["about", "licenses"]; $allowedPageSlugs = ["about", "licenses", "charte"];
$allowedApropos = ["contacts", "erg_site_url", "source_code_url"]; $allowedApropos = ["contacts", "erg_site_url", "source_code_url"];
$pageSlug = $_GET["slug"] ?? ""; $pageSlug = $_GET["slug"] ?? "";

View File

@@ -10,7 +10,7 @@ if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} }
$allowedPageSlugs = ['about', 'licenses']; $allowedPageSlugs = ['about', 'licenses', 'charte'];
try { try {
$db = new Database(); $db = new Database();

View File

@@ -0,0 +1,42 @@
<?php
require_once APP_ROOT . '/src/Database.php';
require_once APP_ROOT . '/src/ErrorHandler.php';
require_once APP_ROOT . '/src/EmailObfuscator.php';
use League\CommonMark\CommonMarkConverter;
class CharteController
{
public static function create(): self
{
return new self();
}
public function handle(): array
{
try {
$db = Database::getInstance();
$dbPage = $db->getPage('charte');
$content = $dbPage ? $dbPage['content'] : '';
$pageTitle = $dbPage ? $dbPage['title'] : 'Charte';
} catch (Exception $e) {
ErrorHandler::log('charte_page', $e);
$content = '';
$pageTitle = 'Charte';
}
$converter = new CommonMarkConverter(['html_input' => 'strip']);
$html = EmailObfuscator::obfuscateHtml($converter->convert($content)->getContent());
return [
'content' => $content,
'html' => $html,
'pageTitle' => $pageTitle . ' XAMXAM',
'metaDescription' => "Charte d'utilisation de XAMXAM, le répertoire des TFE de l'erg.",
'currentNav' => 'charte',
'extraCss' => ['/assets/css/apropos.css'],
'bodyClass' => 'apropos-body',
];
}
}

View File

@@ -13,6 +13,7 @@
* /tfe/<id> → TfeController → tfe view * /tfe/<id> → TfeController → tfe view
* /apropos → AboutController → about view * /apropos → AboutController → about view
* /licence → LicenceController → licence view * /licence → LicenceController → licence view
* /charte → CharteController → charte view
* /media.php → MediaController (direct output) * /media.php → MediaController (direct output)
* /live-reload → LiveReloadController (direct output) * /live-reload → LiveReloadController (direct output)
* /partage/<slug> → share-link flow * /partage/<slug> → share-link flow
@@ -34,6 +35,8 @@ class Dispatcher
'/apropos.php' => ['controller' => 'AboutController', 'action' => 'handle', 'view' => 'public/about'], '/apropos.php' => ['controller' => 'AboutController', 'action' => 'handle', 'view' => 'public/about'],
'/licence' => ['controller' => 'LicenceController', 'action' => 'handle', 'view' => 'public/licence'], '/licence' => ['controller' => 'LicenceController', 'action' => 'handle', 'view' => 'public/licence'],
'/licence.php' => ['controller' => 'LicenceController', 'action' => 'handle', 'view' => 'public/licence'], '/licence.php' => ['controller' => 'LicenceController', 'action' => 'handle', 'view' => 'public/licence'],
'/charte' => ['controller' => 'CharteController', 'action' => 'handle', 'view' => 'public/charte'],
'/charte.php' => ['controller' => 'CharteController', 'action' => 'handle', 'view' => 'public/charte'],
]; ];
private string $path; private string $path;

View File

@@ -16,3 +16,8 @@
{"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"}} {"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"}}
{"timestamp":"2026-06-09T15:23:37+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":"sidebar_links"}} {"timestamp":"2026-06-09T15:23:37+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":"sidebar_links"}}
{"timestamp":"2026-06-09T15:23: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":"apropos","action":"edit","status":"success","context":{"key":"sidebar_links"}} {"timestamp":"2026-06-09T15:23: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":"apropos","action":"edit","status":"success","context":{"key":"sidebar_links"}}
{"timestamp":"2026-06-09T17:47: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-09T17:47: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":"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: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"}}

View File

@@ -50,6 +50,10 @@ $_thesisId = $_GET['id'] ?? null;
<a href="/licence" <a href="/licence"
<?= ($_navCurrent === 'licence') ? 'aria-current="page"' : '' ?>>Licences</a> <?= ($_navCurrent === 'licence') ? 'aria-current="page"' : '' ?>>Licences</a>
</li> </li>
<li>
<a href="/charte"
<?= ($_navCurrent === 'charte') ? 'aria-current="page"' : '' ?>>Charte</a>
</li>
<li> <li>
<a href="/apropos" <a href="/apropos"
<?= ($_navCurrent === 'apropos') ? 'aria-current="page"' : '' ?>>À Propos</a> <?= ($_navCurrent === 'apropos') ? 'aria-current="page"' : '' ?>>À Propos</a>
@@ -68,6 +72,10 @@ $_thesisId = $_GET['id'] ?? null;
<a href="/licence" <a href="/licence"
<?= ($_navCurrent === 'licence') ? 'aria-current="page"' : '' ?>>Licences</a> <?= ($_navCurrent === 'licence') ? 'aria-current="page"' : '' ?>>Licences</a>
</li> </li>
<li>
<a href="/charte"
<?= ($_navCurrent === 'charte') ? 'aria-current="page"' : '' ?>>Charte</a>
</li>
<li> <li>
<a href="/apropos" <a href="/apropos"
<?= ($_navCurrent === 'apropos') ? 'aria-current="page"' : '' ?>>À Propos</a> <?= ($_navCurrent === 'apropos') ? 'aria-current="page"' : '' ?>>À Propos</a>

View File

@@ -0,0 +1,9 @@
<main class="apropos-main" id="main-content">
<div class="prose apropos-single">
<?php if (!empty(trim($content))): ?>
<?= $html ?>
<?php else: ?>
<p>Contenu à venir.</p>
<?php endif; ?>
</div>
</main>

328
docs/autosave-system.md Normal file
View File

@@ -0,0 +1,328 @@
# Autosave System — Architecture & HTMX Migration Assessment
## Overview
The admin panel has a custom JavaScript autosave system (`autosave.js`) that
auto-submits forms after a 1.5s debounce. It is used on pages where content
is edited with the OverType Markdown editor (static pages, form help blocks)
and on structured-data forms (contacts, sidebar links).
Three server-side action handlers implement the same CSRF-rotation + JSON
response contract that `autosave.js` consumes.
## Architecture
```
┌──────────────────────────────────┐
│ autosave.js │
│ ───────── │
│ Watches <form data-autosave> │
│ Listens: input / change (bubble)│
│ Debounce: 1500ms │
│ POST via fetch() │
│ Accept: application/json │
│ On 2xx: shows "Enregistré ✓" │
│ On err: shows "Erreur !", retry │
│ Updates CSRF token from response│
└──────────┬───────────────────────┘
│ POST (JSON)
┌──────────────────────────────────┐
│ Backend handler (page.php, │
│ apropos.php, form-help.php) │
│ ────────────────────────────────│
│ 1. Check CSRF token │
│ 2. Validate payload │
│ 3. Save to DB │
│ 4. Regenerate CSRF token │
│ 5. Return JSON: │
│ {success:true, csrf_token:X} │
└──────────────────────────────────┘
```
## Forms Using `data-autosave`
### 1. Static Pages (about, licenses, charte)
| Attribute | Value |
|---|---|
| **Template** | `app/templates/admin/contenus-edit.php` (branches `about_page` and `page`) |
| **Form action** | `/admin/actions/page.php` |
| **Editor** | OverType (custom `contenteditable`-based Markdown WYSIWYG) |
| **Fields** | `csrf_token` (hidden), `slug` (hidden), `content` (hidden, synced by OverType `onChange`) |
| **How events reach autosave** | User types in OverType's `contenteditable` div → native `input` events bubble up to `<form>` → autosave.js detects them |
**Key interaction**: OverType's `onChange(value) { hiddenInput.value = value }` sets
the hidden `#content` input's value programmatically. This does NOT fire native DOM
events. The autosave trigger comes from `input` events on the contenteditable editor
div, not from the hidden input.
### 2. About Contacts (apropos groups)
| Attribute | Value |
|---|---|
| **Template** | `app/templates/admin/apropos-groups-form.php` |
| **Form action** | `/admin/actions/apropos.php` |
| **Editor** | Native text/email/url inputs |
| **Fields** | `csrf_token`, `apropos_key`, `groups[][role]`, `groups[][entries][][text/email/url]` |
Has custom JS for adding/removing contact groups and entries (inline `<script>`
in the template). The reindex logic updates `name` attributes after add/remove.
### 3. About Sidebar Links
| Attribute | Value |
|---|---|
| **Template** | Inline in `app/templates/admin/contenus-edit.php` (the `about_page` branch) |
| **Form action** | `/admin/actions/apropos.php` |
| **Fields** | `csrf_token`, `apropos_key=sidebar_links`, `links[][label]`, `links[][url]` |
Has custom JS for add/remove/reindex (inline `<script>` in the template).
### 4. Form Help Blocks
| Attribute | Value |
|---|---|
| **Template** | `app/templates/admin/contenus-edit.php` (branch `form_help`) |
| **Form action** | `/admin/actions/form-help.php` |
| **Editor** | OverType (same as static pages) |
| **Fields** | `csrf_token`, `form_help_key`, `content` |
Same OverType + autosave integration as static pages.
## Backend Handlers
### `page.php` — `/admin/actions/page.php`
```
CSRF check → slug validation (about|licenses|charte) → savePage(slug, content)
→ AdminLogger::logPageEdit() → regenerate CSRF → return JSON
```
On AJAX: returns `{success: true, csrf_token: "<new_token>"}`.
On non-AJAX (regular form POST): flashes message, redirects to `/admin/contenus.php`.
### `apropos.php` — `/admin/actions/apropos.php`
```
CSRF check → key validation → dispatch by key type:
URL keys (erg_site_url, source_code_url) → saveAproposContent(key, url)
Link lists (sidebar_links) → validate URLs, save structured array
Group-based (contacts) → validate groups/entries, save structured array
→ AdminLogger::logAproposEdit() → regenerate CSRF → return JSON
```
### `form-help.php` — `/admin/actions/form-help.php`
```
CSRF check → key validation (FORM_HELP_KEYS) → setFormHelpBlock(key, content)
→ AdminLogger::logFormStructureEdit() → regenerate CSRF → return JSON
```
### CSRF Rotation Pattern (shared by all three)
```
1. Verify: hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])
2. Save to DB
3. Rotate: $_SESSION['csrf_token'] = bin2hex(random_bytes(32))
4. Return: {success: true, csrf_token: <NEW>}
5. Client: autosave.js reads data.csrf_token, updates hidden input
```
## Other Autosave-like UI (Non-autosave.js)
These use HTMX directly, NOT `autosave.js`:
| Location | Trigger | Target | Handler |
|---|---|---|---|
| Settings checkboxes (Accès, Types) | `change` via `hx-post` | `#acces-response` / `#types-response` | `/admin/actions/settings.php` |
| TFE messages textareas | `change` via `hx-post` | inline toast | `/admin/actions/settings.php` |
| File restrictions toggle | `change` via `hx-post` | inline toast | `/admin/actions/settings.php` |
| Form help inline editor | Manual `Enregistrer` via `hx-post` | collapsed chip | `/admin/form-help-inline-fragment.php` |
| Form help toggle (dot button) | Click via `hx-post` | collapsed chip | `/admin/form-help-inline-fragment.php` |
| Language inline renames | Manual submit via `hx-post` | `#langues-table-wrap` | `/admin/actions/language.php` |
## autosave.js — Detailed Behavior
```js
// Watches: form[data-autosave]
// Debounce: 1500ms after last input/change event
// Status display: sibling element matching [data-autosave-status]
const DEBOUNCE_MS = 1500;
let timer = null;
let dirty = false;
// schedule() — called on every input/change event
// Sets dirty=true, clears previous timer, starts new 1500ms timer
schedule() clearTimeout(timer); timer = setTimeout(doSave, 1500);
// doSave() — fires after 1500ms of inactivity
doSave() if (!dirty) return; // guard: skip if not dirty
dirty = false; // mark clean (re-set to true on error)
setStatus('Enregistrement…');
const fd = new FormData(form); // snapshots ALL current form values
fetch(form.action, {
method: 'POST',
body: fd,
headers: { 'Accept': 'application/json' }
})
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status); // 403/500/etc → catch
setStatus('Enregistré ✓');
r.json().then(data => {
if (data.csrf_token) {
// Update both form input and meta tag
form.querySelector('input[name="csrf_token"]').value = data.csrf_token;
document.querySelector('meta[name="csrf-token"]').content = data.csrf_token;
}
}).catch(() => {}); // silently ignore JSON parse errors
})
.catch(() => {
setStatus('Erreur !');
dirty = true; // re-arm: will retry on next input
});
```
**Important**: The CSRF token update happens asynchronously inside `.json().then()`,
after the status is already set to "Enregistré ✓". In practice this is fine
because the 1.5s debounce provides ample time for the microtask to complete
before the next save cycle.
If the JSON response is malformed (PHP warnings/notices in output), the
`.catch(() => {})` silently discards the parse error, the CSRF token stays
stale, and all subsequent saves fail with 403.
## Known Issues
### 403 Forbidden on Autosave
Observed on charte page (and possibly about/licenses). The response is:
```
HTTP/1.1 403 Forbidden
{"error": "Erreur de sécurité : token invalide."}
```
The 403 comes from the CSRF check in `page.php` (line ~18):
```php
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
```
Possible causes:
1. **Stale CSRF after error**: First save succeeds and rotates the token, but
`autosave.js` fails to update the form (e.g., JSON parse error from PHP
warning in output). All subsequent saves fail with 403.
2. **Session not initialized**: If `$_SESSION['csrf_token']` is not set when
`page.php` runs. In admin context, it should be set by:
- `contenus.php` (list page): `if (empty($_SESSION['csrf_token'])) { ... }`
- `contenus-edit.php` (edit page): `if (empty($_SESSION['csrf_token'])) { ... }`
- `AdminAuth::requireLogin()` calls `session_start()`
3. **Session collision**: PHP's default file-based session locking serializes
concurrent requests. Unlikely with 1.5s debounce.
4. **OverType → hidden input sync**: OverType's `onChange` updates the hidden
input, but if the OverType script fails to load, the hidden input stays
empty, and the form submission has no content (but CSRF token should still
be present).
### Non-event-emitting value updates
`autosave.js` relies on `input`/`change` events bubbling. The OverType editor
updates `hidden.value` programmatically. If someone adds custom JS that updates
form values without firing events, autosave won't detect those changes.
## HTMX Migration Feasibility
The settings toggles already use HTMX successfully for similar patterns
(checkbox → toggle → server → toast). Here's an assessment for each form:
### 1. Static Pages (page.php) — **Challenging**
The OverType editor is a heavy custom JS component (contenteditable-based
Markdown WYSIWYG). HTMX could handle the *submission* side (replacing
`fetch()` with `hx-post`), but the editor itself would remain custom JS.
An HTMX approach would:
- Add `hx-post="/admin/actions/page.php"` to the form
- Add `hx-trigger="every 3s"` or a custom event dispatched by OverType's `onChange`
- Use `hx-swap="none"` or a status indicator swap
- Need a custom event from OverType like `overtype:changed` to trigger saves
at the right time (not polling-based like `every 3s`)
**Verdict**: Partial replacement possible. HTMX for submission, keep OverType
for editing. Could eliminate ~40 lines of autosave.js logic for this form.
### 2. About Contacts (apropos.php) — **Easy**
Simple text inputs. The add/remove group/entry logic is separate JS. Autosave
just watches `input` events.
HTMX approach:
- `hx-post` with `hx-trigger="change delay:1500ms"` on each input
- `hx-swap="none"` + custom status indicator via `hx-on::after-request`
**Verdict**: Straightforward replacement. Eliminates autosave.js dependency.
### 3. About Sidebar Links (apropos.php) — **Easy**
Same as contacts — simple text/URL inputs. The add/remove JS is independent
of autosave.
**Verdict**: Same as contacts.
### 4. Form Help Blocks (form-help.php) — **Challenging**
Same OverType integration as static pages. Same considerations apply.
**Verdict**: Same as static pages.
### 5. The autosave.js Itself — **Can be Replaced**
If every form with `data-autosave` is converted to HTMX, `autosave.js` can be
removed entirely. The script is ~60 lines.
### Summary Table
| Form | Editor | HTMX Feasibility | Effort |
|---|---|---|---|
| Static pages (about/licenses/charte) | OverType | Partial (keep OverType) | Medium |
| Form help blocks | OverType | Partial (keep OverType) | Medium |
| About contacts | Native inputs | Full | Low |
| About sidebar links | Native inputs | Full | Low |
### Recommended HTMX Strategy
For static pages and form help blocks:
```html
<form hx-post="/admin/actions/page.php"
hx-trigger="overtype:changed delay:1500ms"
hx-swap="none"
hx-on::after-request="
var data = JSON.parse(event.detail.xhr.responseText);
if (data.csrf_token) {
document.querySelector('input[name=csrf_token]').value = data.csrf_token;
}
"
data-autosave>
```
And dispatch `overtype:changed` from OverType's `onChange`:
```js
onChange: function(value) {
hidden.value = value;
editorElement.dispatchEvent(new CustomEvent('overtype:changed', { bubbles: true }));
}
```
For contacts/sidebar links — use per-input `hx-trigger="change delay:1500ms"`
or a form-level trigger on any input change.
The key advantage: HTMX handles the CSRF token rotation natively via
`hx-on::after-request`, eliminating the async `.json().then()` pattern
and the associated silent-parse-error risk.