Files
xamxam/app/templates/admin/contenus.php
Pontoporeia 43064ccbd7 feat(admin): add htmx toast feedback for settings checkboxes in contenus.php
- Replace hx-swap="none" with hx-target on response divs inside each of the
  three fieldsets (Restrictions d'accès, Degré d'ouverture, Types de travaux)
- Add hxToastSuccess / hxToastError helpers in settings.php that return HTML
  toast fragments with self-referencing auto-dismiss after 3s
- Each response div has aria-live="polite" for accessibility
- Add comprehensive PHP/JS debugging logs:
  - settings.php logs raw POST values per field before resolving to 0/1
  - checkboxes have hx-on::before-request and hx-on::after-request console.log
  - global htmx:beforeSend and htmx:sendError listeners in admin footer
  - toast lifecycle logged (creation + removal) for traceability
- Fix toast auto-remove: use getElementById with random unique ID instead
  of querySelector which could remove wrong toast on rapid clicks
- Follows the Django+HTMX ajax checkbox pattern from the reference tutorial

feat(admin): add htmx toast feedback for settings checkboxes in contenus.php

- Replace hx-swap="none" with hx-target on response divs inside each of the
  three fieldsets (Restrictions d'accès, Degré d'ouverture, Types de travaux)
- Add hxToastSuccess / hxToastError helpers in settings.php that return HTML
  toast fragments with self-referencing auto-dismiss after 3s
- Each response div has aria-live="polite" for accessibility
- Add comprehensive PHP/JS debugging logs:
  - settings.php logs raw POST values per field before resolving to 0/1
  - checkboxes have hx-on::before-request and hx-on::after-request console.log
  - global htmx:beforeSend and htmx:sendError listeners in admin footer
  - toast lifecycle logged (creation + removal) for traceability
- Fix toast auto-remove: use getElementById with random unique ID instead
  of querySelector which could remove wrong toast on rapid clicks
- Fix checkbox unresponsive after toggles: move hidden value="0" inputs
  outside <label> to prevent HTML label double-activation
- Follows the Django+HTMX ajax checkbox pattern from the reference tutorial

feat(admin): add htmx toast feedback for settings checkboxes in contenus.php

- Replace hx-swap="none" with hx-target on response divs inside each of the
  three fieldsets (Restrictions d'accès, Degré d'ouverture, Types de travaux)
- Add hxToastSuccess / hxToastError helpers in settings.php that return HTML
  toast fragments with self-referencing auto-dismiss after 3s
- Each response div has aria-live="polite" for accessibility
- Add comprehensive PHP/JS debugging logs:
  - settings.php logs raw POST values per field before resolving to 0/1
  - checkboxes have hx-on::before-request and hx-on::after-request console.log
  - global htmx:beforeSend and htmx:sendError listeners in admin footer
  - toast lifecycle logged (creation + removal) for traceability
- Fix toast auto-remove: use getElementById with random unique ID instead
  of querySelector which could remove wrong toast on rapid clicks
- Fix checkbox unresponsive after toggles: remove hidden value="0" inputs entirely; unchecked checkboxes are simply absent from POST and server treats missing key as 0
  outside <label> to prevent HTML label double-activation
- Follows the Django+HTMX ajax checkbox pattern from the reference tutorial
2026-05-19 00:08:06 +02:00

412 lines
22 KiB
PHP
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<main id="main-content" class="admin-main--toc">
<?php include APP_ROOT . '/templates/admin/partials/admin-toc.php'; ?>
<article>
<h1>Contenus</h1>
<?php
$flash = App::consumeFlash();
?>
<?php if ($flash['success']): ?>
<div class="flash-success" role="alert"><?= htmlspecialchars($flash['success']) ?></div>
<?php endif; ?>
<?php if ($flash['error']): ?>
<div class="flash-error" role="alert"><?= htmlspecialchars($flash['error']) ?></div>
<?php endif; ?>
<!-- ═══════════════════════════════════════════════════════════════════
PAGES STATIQUES
═══════════════════════════════════════════════════════════════════ -->
<section aria-labelledby="static-pages-title">
<h2 id="static-pages-title">Pages statiques</h2>
<table>
<thead>
<tr>
<th scope="col">Slug</th>
<th scope="col">Titre</th>
<th scope="col">Mis à jour</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
<?php foreach ($pages as $p): ?>
<tr>
<td><code><?= htmlspecialchars($p['slug']) ?></code></td>
<td><?= htmlspecialchars($p['title']) ?></td>
<td><?= htmlspecialchars($p['updated_at'] ?? '—') ?></td>
<td>
<a href="/admin/contenus-edit.php?slug=<?= urlencode($p['slug']) ?>"
class="admin-icon-btn admin-icon-btn--edit" title="Éditer">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M248,92.68a15.86,15.86,0,0,0-4.69-11.31L174.63,12.68a16,16,0,0,0-22.63,0L123.57,41.11l-58,21.77A16.06,16.06,0,0,0,55.35,75.23L32.11,214.68A8,8,0,0,0,40,224a8.4,8.4,0,0,0,1.32-.11l139.44-23.24a16,16,0,0,0,12.35-10.17l21.77-58L243.31,104A15.87,15.87,0,0,0,248,92.68Zm-69.87,92.19L63.32,204l47.37-47.37a28,28,0,1,0-11.32-11.32L52,192.7,71.13,77.86,126,57.29,198.7,130ZM112,132a12,12,0,1,1,12,12A12,12,0,0,1,112,132Zm96-15.32L139.31,48l24-24L232,92.68Z"></path></svg>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</section>
<!-- ═══════════════════════════════════════════════════════════════════
DONNÉES SECONDAIRES
═══════════════════════════════════════════════════════════════════ -->
<section aria-labelledby="donnees-secondaires-title">
<h2 id="donnees-secondaires-title">Données Secondaires</h2>
<p><a href="/admin/tags.php" class="btn btn--sm btn--primary">Gérer les mots-clés</a></p>
<fieldset>
<legend>Langues</legend>
<form id="langues-search-form"
hx-get="/admin/contenus-langues-fragment.php"
hx-target="#langues-table-wrap"
hx-swap="innerHTML"
hx-trigger="input changed delay:200ms from:input[name=q], keyup[key=='Enter'] from:input[name=q]"
hx-push-url="false"
style="margin-bottom:var(--space-xs)">
<input type="text" name="q" placeholder="Rechercher une langue…" style="max-width:300px">
</form>
<div id="langues-table-wrap"
hx-get="/admin/contenus-langues-fragment.php"
hx-trigger="load"
hx-swap="innerHTML">
<div style="padding:var(--space-m); text-align:center; color:var(--text-tertiary)">
<img alt="Chargement…" class="htmx-indicator" width="24" src="/assets/img/bars.svg" style="opacity:0.5">
</div>
</div>
</fieldset>
</section>
<!-- ═══════════════════════════════════════════════════════════════════
PARAMÈTRES DU FORMULAIRE
═══════════════════════════════════════════════════════════════════ -->
<section aria-labelledby="form-settings-title">
<h2 id="form-settings-title">Paramètres du Formulaire</h2>
<fieldset id="fieldset-restrictions">
<legend>Restrictions d'accès aux fichiers</legend>
<div class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="formulaire_restrictions">
<label class="param-checkbox">
<input type="checkbox" name="restricted_files_enabled" value="1"
<?= ($siteSettings['restricted_files_enabled'] ?? '0') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-target="#restrictions-response"
hx-swap="innerHTML"
hx-include="#fieldset-restrictions">
<span>
<strong>Activer la restriction d'accès</strong><br>
<small>Pour les TFE de type "Interne", masquer les fichiers et exiger une demande d'accès par email. Les métadonnées et le résumé restent visibles publiquement.</small>
</span>
</label>
</div>
<div id="restrictions-response" aria-live="polite"></div>
</fieldset>
<fieldset id="fieldset-acces">
<legend>Degré d'ouverture</legend>
<p>Options de visibilité disponibles dans le formulaire d'ajout de TFE.</p>
<div class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="formulaire_acces">
<label class="param-checkbox">
<input type="checkbox" name="access_type_libre_enabled" value="1"
<?= ($siteSettings['access_type_libre_enabled'] ?? '0') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-target="#acces-response"
hx-swap="innerHTML"
hx-include="#fieldset-acces">
<span>
<strong>Libre</strong><br>
<small>Libre accès — TFE accessible publiquement sur la plateforme et en bibliothèque</small>
</span>
</label>
<label class="param-checkbox">
<input type="checkbox" name="access_type_interne_enabled" value="1"
<?= ($siteSettings['access_type_interne_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-target="#acces-response"
hx-swap="innerHTML"
hx-include="#fieldset-acces">
<span>
<strong>Interne</strong><br>
<small>TFE accessible uniquement sur place en physique</small>
</span>
</label>
<label class="param-checkbox">
<input type="checkbox" name="access_type_interdit_enabled" value="1"
<?= ($siteSettings['access_type_interdit_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-target="#acces-response"
hx-swap="innerHTML"
hx-include="#fieldset-acces">
<span>
<strong>Interdit</strong><br>
<small>TFE non disponible en physique ni sur le site</small>
</span>
</label>
</div>
<div id="acces-response" aria-live="polite"></div>
</fieldset>
<fieldset id="fieldset-types">
<legend>Types de travaux</legend>
<p>Active ou désactive les types de travaux dans les formulaires et la consultation. Un type désactivé ne peut plus être soumis ni affiché sur le site.</p>
<p class="param-note">Le type <strong>TFE</strong> est toujours actif et ne peut pas être désactivé.</p>
<div class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="objet_types">
<label class="param-checkbox param-checkbox--disabled">
<input type="checkbox" disabled checked>
<span>
<strong>TFE</strong><br>
<small>Travail de fin d'études — toujours actif</small>
</span>
</label>
<label class="param-checkbox">
<input type="checkbox" name="objet_these_enabled" value="1"
<?= ($siteSettings['objet_these_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-target="#types-response"
hx-swap="innerHTML"
hx-include="#fieldset-types">
<span>
<strong>Thèse</strong><br>
<small>Thèses doctorales</small>
</span>
</label>
<label class="param-checkbox">
<input type="checkbox" name="objet_frart_enabled" value="1"
<?= ($siteSettings['objet_frart_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-target="#types-response"
hx-swap="innerHTML"
hx-include="#fieldset-types">
<span>
<strong>Frart</strong><br>
<small>Formation de recherche en art</small>
</span>
</label>
</div>
<div id="types-response" aria-live="polite"></div>
</fieldset>
<fieldset>
<legend>Structure du Formulaire</legend>
<p class="fhb-hint">
Chaque <strong>bloc d'aide</strong> s'affiche au-dessus de sa section dans le formulaire de soumission.
Le <strong>bouton rond</strong> active/désactive l'affichage.
</p>
<?php
$blocks = $formHelpBlocks;
$pairs = [
['partage_intro', null, null],
['fieldset_tfe_info', 'Informations du TFE',
['Titre', 'Sous-titre', 'Auteur·ice(s)', 'Contact visible', 'Synopsis']],
['fieldset_languages', 'Langue(s)',
['Langues du TFE (cases à cocher)', 'Autre(s) langue(s)']],
['fieldset_keywords', 'Mots-clés',
['Mots-clés (max 10), séparés par des virgules']],
['fieldset_academic', 'Cadre académique',
['Année', 'Orientation', 'AP', 'Finalité']],
['fieldset_jury', 'Composition du jury',
['Président·e', 'Promoteur·ice(s)', 'Lecteur·ices']],
['fieldset_files', 'Format(s) + Fichiers',
['Formats (PDF, vidéo, audio, site web…)', 'Couverture', 'Note d\'intention', 'Fichier principal', 'Annexes']],
['fieldset_access', 'Degrés d\'ouverture et licences',
['Généralités', 'Degré (libre/interne/interdit)', 'Licence', 'CC2r']],
['fieldset_email', 'E-mail de confirmation',
['Adresse e-mail']],
];
?>
<div class="fhb-structure">
<?php foreach ($pairs as [$helpKey, $fieldsetName, $inputs]):
$b = $blocks[$helpKey] ?? ['content' => '', 'name' => '', 'enabled' => 0];
$title = $b['name'] ?: ($fieldsetName ?? $helpKey);
?>
<div class="fhb-block-wrapper" data-key="<?= htmlspecialchars($helpKey) ?>">
<div class="fhb-inline"
hx-get="/admin/form-help-inline-fragment.php?key=<?= urlencode($helpKey) ?>"
hx-trigger="load"
hx-swap="outerHTML">
<div class="fhb-inline-name"><?= htmlspecialchars($title) ?></div>
</div>
</div>
<?php if ($fieldsetName !== null): ?>
<div class="fhb-fieldset-card">
<div class="fhb-fieldset-card-legend"><?= htmlspecialchars($fieldsetName) ?></div>
<?php if ($inputs): ?>
<ul class="fhb-fieldset-card-inputs">
<?php foreach ($inputs as $inp): ?>
<li><?= htmlspecialchars($inp) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<?php endif; ?>
<?php endforeach; ?>
</div>
</fieldset>
</section>
</article>
</main>
<!-- ══════════════════════════════════════════════════════════════
CONFIRM DIALOGS FOR LANGUES
══════════════════════════════════════════════════════════════ -->
<dialog id="langues-delete-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="langues-delete-title">
<div class="admin-dialog__header">
<h2 id="langues-delete-title">Supprimer la langue</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="this.closest('dialog').close()">&#x2715;</button>
</div>
<div class="admin-dialog__alert">
<p>Supprimer « <strong id="langues-delete-name"></strong> » ? Cette action est irréversible.</p>
</div>
<div class="admin-dialog__footer">
<button type="button" class="btn btn--danger" onclick="this.closest('dialog').close(); languesSubmitPending()">Supprimer</button>
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div>
</dialog>
<dialog id="langues-bulk-merge-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="langues-bulk-merge-title">
<div class="admin-dialog__header">
<h2 id="langues-bulk-merge-title">Fusionner des langues</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="this.closest('dialog').close()">&#x2715;</button>
</div>
<div class="admin-dialog__alert">
<p>Fusionner <strong><span id="langues-bulk-merge-count"></span> langue(s)</strong> sélectionnée(s) dans :</p>
<select id="langues-bulk-merge-target-select" class="admin-select--inline" style="margin-top:var(--space-xs); width:100%" required>
<option value="">— Choisir la destination —</option>
</select>
</div>
<div class="admin-dialog__footer">
<button type="button" class="btn btn--warning" onclick="languesExecBulkMerge()">Fusionner</button>
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div>
</dialog>
<script>
let _languesPendingForm = null;
function languesConfirmDelete(btn, name) {
_languesPendingForm = btn.closest('form');
document.getElementById('langues-delete-name').textContent = name;
document.getElementById('langues-delete-dialog').showModal();
}
// ── Inline rename via HTMX ──────────────────────────────────────────────
function languesStartRename(id) {
var cell = document.getElementById('lang-name-' + id);
var csrf = document.querySelector('input[name="csrf_token"]').value;
cell.innerHTML = '<form hx-post="/admin/actions/language.php" hx-target="#langues-table-wrap" hx-swap="innerHTML" class="admin-inline-form">'
+ '<input type="hidden" name="csrf_token" value="' + csrf + '">'
+ '<input type="hidden" name="action" value="rename">'
+ '<input type="hidden" name="return" value="/admin/contenus.php">'
+ '<input type="hidden" name="language_id" value="' + id + '">'
+ '<input type="text" name="new_name" value="' + cell.getAttribute('data-name') + '" required class="admin-input--inline">'
+ '<button type="submit" class="admin-icon-btn admin-icon-btn--edit" title="Valider">'
+ '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"/></svg>'
+ '</button>'
+ '<button type="button" class="admin-icon-btn admin-icon-btn--delete" onclick="languesCancelRename(' + id + ')" title="Annuler">'
+ '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M208.49,191.51a12,12,0,0,1-17,17L128,145,64.49,208.49a12,12,0,0,1-17-17L111,128,47.51,64.49a12,12,0,0,1,17-17L128,111l63.51-63.52a12,12,0,0,1,17,17L145,128Z"/></svg>'
+ '</button></form>';
cell.querySelector('input').focus();
}
function languesCancelRename(id) {
var cell = document.getElementById('lang-name-' + id);
cell.innerHTML = '<span class="tag-name-cell">' + cell.getAttribute('data-name') + '</span>'
+ '<button type="button" class="admin-icon-btn admin-icon-btn--edit" title="Renommer" onclick="languesStartRename(' + id + ')">'
+ '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M248,92.68a15.86,15.86,0,0,0-4.69-11.31L174.63,12.68a16,16,0,0,0-22.63,0L123.57,41.11l-58,21.77A16.06,16.06,0,0,0,55.35,75.23L32.11,214.68A8,8,0,0,0,40,224a8.4,8.4,0,0,0,1.32-.11l139.44-23.24a16,16,0,0,0,12.35-10.17l21.77-58L243.31,104A15.87,15.87,0,0,0,248,92.68Zm-69.87,92.19L63.32,204l47.37-47.37a28,28,0,1,0-11.32-11.32L52,192.7,71.13,77.86,126,57.29,198.7,130ZM112,132a12,12,0,1,1,12,12A12,12,0,0,1,112,132Zm96-15.32L139.31,48l24-24L232,92.68Z"/></svg>'
+ '</button>';
}
function languesSubmitPending() {
if (_languesPendingForm) _languesPendingForm.submit();
}
function languesToggleAll(src) {
document.querySelectorAll('input[name="selected_langs[]"]').forEach(cb => cb.checked = src.checked);
languesUpdateBulk();
}
function languesUpdateBulk() {
const n = document.querySelectorAll('input[name="selected_langs[]"]:checked').length;
document.getElementById('langues-selected-count').textContent = n;
document.getElementById('langues-bulk-actions').style.display = n > 1 ? 'flex' : 'none';
}
function languesConfirmBulkMerge() {
const checked = document.querySelectorAll('input[name="selected_langs[]"]:checked');
if (checked.length < 2) return;
document.getElementById('langues-bulk-merge-count').textContent = checked.length;
const sel = document.getElementById('langues-bulk-merge-target-select');
sel.innerHTML = '<option value="">— Choisir la destination —</option>';
checked.forEach(cb => {
const tr = cb.closest('tr');
sel.innerHTML += '<option value="' + cb.value + '">' + tr.querySelector('td:nth-child(2)').textContent.trim() + '</option>';
});
document.getElementById('langues-bulk-merge-dialog').showModal();
}
function languesExecBulkMerge() {
const targetId = document.getElementById('langues-bulk-merge-target-select').value;
if (!targetId) return;
document.getElementById('langues-bulk-target').value = targetId;
const container = document.getElementById('langues-bulk-checkboxes');
container.innerHTML = '';
document.querySelectorAll('input[name="selected_langs[]"]:checked').forEach(cb => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'selected_langs[]';
inp.value = cb.value;
container.appendChild(inp);
});
document.getElementById('langues-bulk-merge-dialog').close();
document.getElementById('langues-bulk-form').submit();
}
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.target.id === 'langues-table-wrap') {
document.querySelectorAll('input[name="selected_langs[]"]').forEach(cb => cb.addEventListener('change', languesUpdateBulk));
languesUpdateBulk();
}
});
</script>
<script>
(function () {
var otScript = document.createElement('script');
otScript.src = '<?= App::assetV('/assets/js/overtype.min.js') ?>';
document.head.appendChild(otScript);
})();
</script>