feat: require 3 mots-clés in partage, language asterisk toggle, admin auto-save checkboxes

- tag-search: add minTags/required params, counter shows red if < 3, accent if ≥ 3
- form.php: pass minTags=3 for partage mode keywords
- checkbox-list: support labelHtml for raw HTML label with targetable asterisk span
- language-autre-fragment: OOB swap updates #languages-required-asterisk when autre pills change
- language-search: client-side update #languages-required-asterisk on pill add/remove
- contenus.php: replace 3 form+submit-button fieldsets with HTMX auto-save checkboxes
- settings.php: detect HX-Request header, return OOB CSRF token updates, skip redirect
This commit is contained in:
Pontoporeia
2026-05-10 23:49:43 +02:00
parent 48da914bc8
commit cf9bd5cd5d
12 changed files with 328 additions and 39 deletions

View File

@@ -18,6 +18,7 @@ require_once APP_ROOT . '/src/AdminLogger.php';
$db = new Database();
$logger = AdminLogger::make();
$isHxRequest = (isset($_SERVER['HTTP_HX_REQUEST']) && $_SERVER['HTTP_HX_REQUEST'] === 'true');
$section = $_POST['section'] ?? '';
if ($section === 'formulaire') {
@@ -34,7 +35,9 @@ if ($section === 'formulaire') {
$newValues[$key] = $value;
}
$logger->logFormSettingsUpdate($newValues);
App::flash('success', "Paramètres du formulaire mis à jour.");
if (!$isHxRequest) {
App::flash('success', "Paramètres du formulaire mis à jour.");
}
} elseif ($section === 'objet_types') {
$newValues = [
'objet_these_enabled' => isset($_POST['objet_these_enabled']) ? '1' : '0',
@@ -43,7 +46,9 @@ if ($section === 'formulaire') {
$db->setSetting('objet_these_enabled', $newValues['objet_these_enabled']);
$db->setSetting('objet_frart_enabled', $newValues['objet_frart_enabled']);
$logger->logObjetTypesUpdate($newValues);
App::flash('success', "Types de travaux mis à jour.");
if (!$isHxRequest) {
App::flash('success', "Types de travaux mis à jour.");
}
} elseif ($section === 'smtp') {
$smtpData = [
'host' => $_POST['smtp_host'] ?? '',
@@ -96,5 +101,15 @@ if ($section === 'formulaire') {
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
if ($isHxRequest) {
// Return updated CSRF tokens for all three hidden inputs on the page
$newToken = htmlspecialchars($_SESSION['csrf_token']);
echo '<input type="hidden" id="csrf_token_files" value="' . $newToken . '" hx-swap-oob="true">';
echo '<input type="hidden" id="csrf_token_acces" value="' . $newToken . '" hx-swap-oob="true">';
echo '<input type="hidden" id="csrf_token_types" value="' . $newToken . '" hx-swap-oob="true">';
exit;
}
header('Location: /admin/parametres.php');
exit;

View File

@@ -18,5 +18,16 @@ $selectedIds = isset($_POST['languages']) && is_array($_POST['languages'])
? $_POST['languages']
: [];
$anyChecked = !empty($selectedIds);
// Also check if any "autre" language pills are present (posted as language_autre[])
$hasLangAutre = isset($_POST['language_autre']) && is_array($_POST['language_autre'])
&& count(array_filter($_POST['language_autre'], fn($l) => is_string($l) && trim($l) !== '')) > 0;
// The "Autre(s) langue(s)" label is required if no standard language is checked.
// The "Langue(s) du TFE" checkbox list is required if neither standard languages
// nor "autre" languages are set.
$langAutreRequired = !$anyChecked;
$checkboxesRequired = !$anyChecked && !$hasLangAutre;
?>
<span id="language-autre-required"><?= !$anyChecked ? ' <span class="asterisk">*</span>' : '' ?></span>
<span id="language-autre-required"><?= $langAutreRequired ? ' <span class="asterisk">*</span>' : '' ?></span>
<span id="languages-required-asterisk" hx-swap-oob="true"><?= $checkboxesRequired ? ' <span class="asterisk">*</span>' : '' ?></span>

View File

@@ -545,6 +545,19 @@
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: nqmqrqmo dd511b0d "fix: obfuscate email in contact links, raise rate limits, make Libre toggleable" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: nqmqrqmo dd511b0d "fix: obfuscate email in contact links, raise rate limits, make Libre toggleable" (rebased revision)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
- $linkName = $link['name'] ?? '';
- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: olzzwmwr 277c8ce4 "feat: require 3 mots-clés in partage, language asterisk toggle, admin auto-save checkboxes" (rebased revision)
$linkName = $link['name'] ?? '';
$linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
$linkLockedYear = $link['locked_year'] ?? null;
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: olzzwmwr 82533c5a "feat: require 3 mots-clés in partage, language asterisk toggle, admin auto-save checkboxes" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
?>
<tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer">

View File

@@ -88,34 +88,40 @@
<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">
<div class="param-form">
<input type="hidden" id="csrf_token_files" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<label class="param-checkbox">
<input type="checkbox" name="restricted_files_enabled" value="1"
<?= ($siteSettings['restricted_files_enabled'] ?? '0') === '1' ? 'checked' : '' ?>>
<?= ($siteSettings['restricted_files_enabled'] ?? '0') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-swap="none"
hx-include="#csrf_token_files"
hx-vals='{"section":"formulaire"}'>
<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>
</div>
</fieldset>
<fieldset>
<legend>Degré d'ouverture</legend>
<p>Options de visibilité disponibles dans le formulaire d'ajout de TFE.</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">
<div class="param-form">
<input type="hidden" id="csrf_token_acces" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<label class="param-checkbox">
<input type="checkbox" name="access_type_libre_enabled" value="1"
<?= ($siteSettings['access_type_libre_enabled'] ?? '0') === '1' ? 'checked' : '' ?>>
<?= ($siteSettings['access_type_libre_enabled'] ?? '0') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-swap="none"
hx-include="#csrf_token_acces"
hx-vals='{"section":"formulaire"}'>
<span>
<strong>Libre</strong><br>
<small>Libre accès — TFE accessible publiquement sur la plateforme et en bibliothèque</small>
@@ -124,7 +130,12 @@
<label class="param-checkbox">
<input type="checkbox" name="access_type_interne_enabled" value="1"
<?= ($siteSettings['access_type_interne_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
<?= ($siteSettings['access_type_interne_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-swap="none"
hx-include="#csrf_token_acces"
hx-vals='{"section":"formulaire"}'>
<span>
<strong>Interne</strong><br>
<small>TFE accessible uniquement sur place en physique</small>
@@ -133,15 +144,18 @@
<label class="param-checkbox">
<input type="checkbox" name="access_type_interdit_enabled" value="1"
<?= ($siteSettings['access_type_interdit_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
<?= ($siteSettings['access_type_interdit_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-swap="none"
hx-include="#csrf_token_acces"
hx-vals='{"section":"formulaire"}'>
<span>
<strong>Interdit</strong><br>
<small>TFE non disponible en physique ni sur le site</small>
</span>
</label>
<button type="submit" class="btn btn--primary">Enregistrer</button>
</form>
</div>
</fieldset>
<fieldset>
@@ -149,9 +163,8 @@
<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">
<div class="param-form">
<input type="hidden" id="csrf_token_types" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<label class="param-checkbox param-checkbox--disabled">
<input type="checkbox" disabled checked>
@@ -163,7 +176,12 @@
<label class="param-checkbox">
<input type="checkbox" name="objet_these_enabled" value="1"
<?= ($siteSettings['objet_these_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
<?= ($siteSettings['objet_these_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-swap="none"
hx-include="#csrf_token_types"
hx-vals='{"section":"objet_types"}'>
<span>
<strong>Thèse</strong><br>
<small>Thèses doctorales</small>
@@ -172,15 +190,18 @@
<label class="param-checkbox">
<input type="checkbox" name="objet_frart_enabled" value="1"
<?= ($siteSettings['objet_frart_enabled'] ?? '1') === '1' ? 'checked' : '' ?>>
<?= ($siteSettings['objet_frart_enabled'] ?? '1') === '1' ? 'checked' : '' ?>
hx-post="/admin/actions/settings.php"
hx-trigger="change"
hx-swap="none"
hx-include="#csrf_token_types"
hx-vals='{"section":"objet_types"}'>
<span>
<strong>Frart</strong><br>
<small>Formation de recherche en art</small>
</span>
</label>
<button type="submit" class="btn btn--primary">Enregistrer</button>
</form>
</div>
</fieldset>
<fieldset>

View File

@@ -9,7 +9,8 @@
*
* Variables consumed:
* string $name — input name attribute (will be posted as array: name[])
* string $label — group label text
* string $label — group label text (htmlspecialchars'd unless $labelHtml is set)
* string $labelHtml — raw HTML label override (bypasses htmlspecialchars, optional)
* array $options — each element must have 'id' and 'name' keys
* array $checked — array of 'id' values that are currently checked
* bool $required — whether at least one checkbox must be checked; default false
@@ -27,7 +28,7 @@ $hxSwap = $hxSwap ?? 'outerHTML';
$hxInclude = $hxInclude ?? 'this';
?>
<div>
<span class="admin-row-label"><?= htmlspecialchars($label) ?><?= $required ? ' <span class="asterisk">*</span>' : '' ?></span>
<span class="admin-row-label"><?= isset($labelHtml) ? $labelHtml : (htmlspecialchars($label) . ($required ? ' <span class="asterisk">*</span>' : '')) ?></span>
<fieldset class="admin-checkbox-group"
<?= $required ? ' required aria-required="true"' : '' ?>
<?php if ($hxPost !== ''): ?>

View File

@@ -193,12 +193,14 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
$label = "Langue(s) du TFE :";
$options = $languages;
$checked = $formData["languages"] ?? [];
$required = !$adminMode;
$_hasLangAutre = !empty($formData['language_autre']) && is_array($formData['language_autre']) && count(array_filter($formData['language_autre'], fn($l) => is_string($l) && trim($l) !== '')) > 0;
$required = !$adminMode && !$_hasLangAutre;
$hxPost = $mode === 'partage' ? "/partage/language-autre-fragment" : "/admin/language-autre-fragment.php";
$hxTarget = "#language-autre-required";
$hxTarget = "#languages-required-asterisk";
$hxSwap = "outerHTML";
$labelHtml = htmlspecialchars($label) . '<span id="languages-required-asterisk">' . ($required ? ' <span class="asterisk">*</span>' : '') . '</span>';
include APP_ROOT . "/templates/partials/form/checkbox-list.php";
unset($hxSwap);
unset($hxSwap, $_hasLangAutre, $labelHtml);
?>
<?php
$_langAutreRequired = empty($formData["languages"]);
@@ -274,9 +276,11 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
$placeholder = "Rechercher un mot-clé…";
$hint = "Tapez pour rechercher ou créer des mots-clés.";
$selectedTags = $_selectedTags;
$required = !$adminMode;
$minTags = ($mode === 'partage') ? 3 : 0;
$hxPost = ($mode === 'partage') ? "/partage/tag-search-fragment" : "/admin/tag-search-fragment.php";
include APP_ROOT . "/templates/partials/form/tag-search.php";
unset($_tagsRaw, $_selectedTags, $_t, $name, $label, $placeholder, $hint, $selectedTags, $hxPost);
unset($_tagsRaw, $_selectedTags, $_t, $name, $label, $placeholder, $hint, $selectedTags, $hxPost, $minTags, $required);
?>
</fieldset>

View File

@@ -100,6 +100,14 @@ $langCount = count($selectedLanguages);
if (countEl) countEl.textContent = n + '/' + maxLanguages;
if (counter) counter.style.display = (n > 0) ? '' : 'none';
// Toggle the checkbox-list asterisk: if any "autre" language pill
// is present, the checkbox list is no longer required.
const asteriskEl = document.getElementById('languages-required-asterisk');
if (asteriskEl) {
const checkboxes = document.querySelectorAll('#languages-fieldset input[type="checkbox"]:checked');
asteriskEl.innerHTML = (n === 0 && checkboxes.length === 0) ? ' <span class="asterisk">*</span>' : '';
}
// Show/hide search input based on max
const wrap = container.querySelector('.tag-search-input-wrap');
const maxMsg = container.querySelector('.tag-search-max-msg');

View File

@@ -17,6 +17,8 @@
* array $selectedTags — array of ['id' => int|null, 'name' => string] for pre-filled tags
* string|null $id — override the id attribute prefix
* int $maxTags — maximum number of tags (default 10)
* int $minTags — minimum required tags (default 0, no requirement)
* bool $required — whether minTags enforcement applies (default false)
*/
$name = $name ?? 'tag';
@@ -27,10 +29,13 @@ $hxPost = $hxPost ?? '/admin/tag-search-fragment.php';
$selectedTags = $selectedTags ?? [];
$id = $id ?? $name;
$maxTags = $maxTags ?? 10;
$minTags = $minTags ?? 0;
$required = $required ?? false;
$tagCount = count($selectedTags);
$belowMin = $required && $tagCount < $minTags;
?>
<div id="<?= htmlspecialchars($id) ?>-search-container">
<span class="admin-row-label"><?= htmlspecialchars($label) ?></span>
<span class="admin-row-label"><?= htmlspecialchars($label) ?><?= $required ? ' <span class="asterisk">*</span>' : '' ?></span>
<div class="tag-search-wrapper">
<?php if ($hint): ?>
<small class="tag-search-hint"><?= htmlspecialchars($hint) ?></small>
@@ -49,9 +54,9 @@ $tagCount = count($selectedTags);
<?php endforeach; ?>
</div>
<!-- Counter visible only when tags exist or max reached -->
<div class="tag-search-counter" id="<?= htmlspecialchars($id) ?>-counter"<?= $tagCount === 0 ? ' style="display:none"' : '' ?>>
<span class="tag-search-count" id="<?= htmlspecialchars($id) ?>-count"><?= $tagCount ?>/<?= (int)$maxTags ?></span>
<!-- Counter visible only when tags exist or max reached, or min requirement active -->
<div class="tag-search-counter" id="<?= htmlspecialchars($id) ?>-counter"<?= ($tagCount === 0 && !$required) ? ' style="display:none"' : '' ?>>
<span class="tag-search-count" id="<?= htmlspecialchars($id) ?>-count"<?= $belowMin ? ' style="color:var(--text-danger)"' : ($tagCount >= $minTags && $required ? ' style="color:var(--accent)"' : '') ?>><?= $tagCount ?>/<?= (int)$maxTags ?><?= $required ? ' (min ' . (int)$minTags . ')' : '' ?></span>
<?php if ($tagCount >= $maxTags): ?>
<span class="tag-search-max-msg">Maximum de mots-clés atteint</span>
<?php endif; ?>
@@ -90,13 +95,23 @@ $tagCount = count($selectedTags);
const countEl = document.getElementById(<?= json_encode($id . '-count') ?>);
const counter = document.getElementById(<?= json_encode($id . '-counter') ?>);
const maxTags = <?= (int)$maxTags ?>;
const minTags = <?= (int)$minTags ?>;
const required = <?= json_encode($required) ?>;
const inputName = <?= json_encode($name) ?>;
let selectedIdx = -1;
function updateCount() {
const n = pills.querySelectorAll('.tag-pill').length;
if (countEl) countEl.textContent = n + '/' + maxTags;
if (counter) counter.style.display = (n > 0) ? '' : 'none';
const suffix = required ? ' (min ' + minTags + ')' : '';
if (countEl) countEl.textContent = n + '/' + maxTags + suffix;
if (counter) counter.style.display = (n > 0 || required) ? '' : 'none';
if (countEl && required) {
if (n < minTags) {
countEl.style.color = 'var(--text-danger)';
} else {
countEl.style.color = 'var(--accent)';
}
}
// Show/hide search input based on max
const wrap = container.querySelector('.tag-search-input-wrap');