Files
xamxam/app/templates/admin/contenus.php
Pontoporeia be50ac5eb0 fix(production): fix multiple remote server errors from nginx logs
- Fix 413 Request Entity Too Large: bump client_max_body_size to 256M,
  PHP post_max_size/upload_max_filesize to 256M, fastcgi timeouts to 300s
- Fix missing v_smtp_active view: add IF NOT EXISTS to all CREATE VIEW
  statements in schema.sql for idempotent migrates
- Fix bars.svg 404: create animated SVG spinner in app/public/assets/img/
- Fix nginx rate limiting: increase admin zone from 60r/m (1 r/s) to
  300r/m (5 r/s) with burst=30 to handle ~11 concurrent HTMX fragment
  GETs on contenus.php page load
- Add deploy-nginx recipe to justfile for uploading nginx config to server
- Database readonly issue mitigated by existing --chown + deploy-server.sh
  permissions fix
- Add comprehensive PHP/JS debugging logs for settings checkboxes:
  per-field raw POST values in error_log, console.log on htmx:beforeSend,
  htmx:sendError, htmx:afterRequest, toast lifecycle
- Fix toast auto-remove script: use getElementById with unique ID instead
  of querySelector which could remove wrong toast on rapid clicks
2026-05-19 00:08:06 +02:00

424 lines
24 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"
hx-on::before-request="console.log('[restrictions] sending checked=' + this.checked + ' POST keys will include all #fieldset-restrictions inputs')"
hx-on::after-request="console.log('[restrictions] response received')">
<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"
hx-on::before-request="console.log('[acces-libre] sending checked=' + this.checked)"
hx-on::after-request="console.log('[acces-libre] response received')">
<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"
hx-on::before-request="console.log('[acces-interne] sending checked=' + this.checked)"
hx-on::after-request="console.log('[acces-interne] response received')">
<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"
hx-on::before-request="console.log('[acces-interdit] sending checked=' + this.checked)"
hx-on::after-request="console.log('[acces-interdit] response received')">
<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"
hx-on::before-request="console.log('[types-these] sending checked=' + this.checked)"
hx-on::after-request="console.log('[types-these] response received')">
<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"
hx-on::before-request="console.log('[types-frart] sending checked=' + this.checked)"
hx-on::after-request="console.log('[types-frart] response received')">
<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>