mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 08:09:18 +02:00
Add Charte static page (public + admin editing)
This commit is contained in:
34
TODO.md
34
TODO.md
@@ -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.
|
|
||||||
|
|||||||
@@ -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'] ?? '';
|
||||||
|
|
||||||
|
|||||||
@@ -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"] ?? "";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
42
app/src/Controllers/CharteController.php
Normal file
42
app/src/Controllers/CharteController.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
9
app/templates/public/charte.php
Normal file
9
app/templates/public/charte.php
Normal 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
328
docs/autosave-system.md
Normal 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.
|
||||||
Reference in New Issue
Block a user