Files
xamxam/app/templates/admin/contenus.php
Pontoporeia a3ded16915 Add sidebar TOC, simplify Données Secondaires section
- Rename 'Éditer Données Secondaires' → 'Données Secondaires', remove fieldset wrapper on Mots-clés link
- Create admin-toc.php partial: IntersectionObserver-based sidebar nav
- Include TOC on contenus.php, acces.php, parametres.php
- Add .admin-with-toc flex layout (sidebar + main) and .admin-toc CSS
- Fonts (Ductus, BBB DM Sans): verified loaded via variables.css → common.css import chain
- TOC: move inside <main> as <aside>, content in <article>, fix scrolling
- Lazy load: hx-trigger='load delay:100ms' with spinner (htmx-indicator) for tags/langues
- Inline rename: edit button in Nom cell, HTMX post for rename, validate+ cancel buttons
- Checkbox column: width:1% / fit-content
- Remove per-row merge forms/selects, only bulk merge when ≥2 checkboxes selected
- Remove per-row merge dialogs, keep only bulk merge and delete dialogs
- Add htmx-settling CSS transition for lazy-load fade-in
- Update acces.php/parametres.php: article layout, TOC inside main
- TOC: DOMContentLoaded guard, use <nav>+<a> directly instead of <ul>/<li>
- Section spacing: margin-bottom on sections and fieldsets in admin-main--toc
- Language dedup: GROUP BY LOWER(name) in getAllLanguagesWithCount and searchLanguages
- deduplicateLanguages() merges duplicate names and reassigns thesis_languages
- Sticky bulk-actions: position:sticky;top:0;z-index:10
- Tags toolbar: title left, stat count right (margin-left:auto), search bar under
- Tags count stat updated via hx-swap-oob from fragment
- Remove margin/max-width from .admin-main--toc
- Gap between TOC and article: --space-xs, sticky top: --space-xs
- Main padding: --space-s / --space-m / --space-xl (was --space-l/--space-l/--space-2xl)
- Article padding-top: --space-m
2026-05-19 00:08:05 +02:00

383 lines
21 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>
<legend>Restrictions d'accès aux fichiers</legend>
<form method="post" action="actions/settings.php" class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="formulaire">
<label class="param-checkbox">
<input type="checkbox" name="restricted_files_enabled" value="1"
<?= ($siteSettings['restricted_files_enabled'] ?? '0') === '1' ? 'checked' : '' ?>>
<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>
<button type="submit" class="btn btn--primary">Enregistrer</button>
</form>
</fieldset>
<fieldset>
<legend>Degré d'ouverture</legend>
<p>Options de visibilité disponibles dans le formulaire d'ajout de TFE.</p>
<p class="param-note">L'option <strong>Libre</strong> ne sera activée qu'à partir de l'année académique prochaine.</p>
<form method="post" action="actions/settings.php" class="param-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="section" value="formulaire">
<label class="param-checkbox">
<input type="checkbox" name="access_type_interdit_enabled" value="1"
<?= ($siteSettings['access_type_interdit_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
<span>
<strong>Interdit</strong><br>
<small>TFE non disponible en physique ni sur le site</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' : '' ?>>
<span>
<strong>Interne</strong><br>
<small>TFE accessible uniquement sur place en physique</small>
</span>
</label>
<label class="param-checkbox param-checkbox--disabled">
<input type="checkbox" name="access_type_libre_enabled" value="1"
<?= ($siteSettings['access_type_libre_enabled'] ?? '0') === '1' ? 'checked' : '' ?>>
<span>
<strong>Libre</strong><br>
<small>Libre accès — disponible à partir de l'année académique prochaine</small>
</span>
</label>
<button type="submit" class="btn btn--primary">Enregistrer</button>
</form>
</fieldset>
<fieldset>
<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>
<form method="post" action="actions/settings.php" 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' : '' ?>>
<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' : '' ?>>
<span>
<strong>Frart</strong><br>
<small>Formation de recherche en art</small>
</span>
</label>
<button type="submit" class="btn btn--primary">Enregistrer</button>
</form>
</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>