Add language-search component for Autre Langue input + active search in lists

Mirrors the mots-clé tag-search system: dropdown suggestions from
existing languages via HTMX, pill display with bin-icon remove buttons,
'Créer' option for new languages. Replaces the plain text input.

- New partial: templates/partials/form/language-search.php
- New fragment: public/partage/language-search-fragment.php
- Admin wrapper: public/admin/language-search-fragment.php
- Updated language-autre-fragment to return just the required asterisk indicator
- Updated both controllers to handle language_autre as array (pill-based)
  with backward-compatible string path
- Updated edit form to compute selectedOtherLanguages from DB
- Registered new route in partage/index.php
- Fix CSV importer: split comma-separated language column into individual entries
- Add htmx active search to admin index, title line-clamp, predefined languages only in checkboxes
- Admin index: filter form now uses htmx triggers (input delay:300ms on search,
  change on selects) to actively search without page reload
- Sort links include hx-push-url for back-button support
- Added loading indicator bar (.admin-search-indicator)
- Title column: line-clamp at 2 lines with overflow hidden, native title attr
  tooltip for full text
- Language checkboxes now show only 3 predefined languages (Français, Anglais,
  Néerlandais); all others go via the Autre langue search component
- Added Database::getPredefinedLanguages() and excluded predefined from
  language-search-fragment suggestions
- Included hidden sort/dir inputs in table-wrap so sort state preserved across
  filter changes
- Fix language-search: block 'Créer' for predefined languages in dropdown
  The 'Créer' option in the language-search dropdown now also checks against the
  predefined set (français, anglais, néerlandais) to avoid offering creation of
  languages that already exist as checkboxes.
This commit is contained in:
Pontoporeia
2026-05-10 10:59:52 +02:00
parent 96fa8ee266
commit 048a14bc2e
22 changed files with 667 additions and 237 deletions

View File

@@ -100,6 +100,19 @@
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: sttrwkly ec5606f5 "CSV importer: boolean and ap variants/typos" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: sttrwkly ec5606f5 "CSV importer: boolean and ap variants/typos" (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: qxuprqpt da941497 "Add language-search component for Autre Langue input + active search in lists" (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: qxuprqpt a1b3064d "Add language-search component for Autre Langue input + active search in lists" (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

@@ -11,6 +11,116 @@
<div class="flash-error" role="alert"><?= htmlspecialchars($flash['error']) ?></div>
<?php endif; ?>
<!-- ═══════════════════════════════════════════════════════════════════
PARAMÈTRES DU FORMULAIRE
═══════════════════════════════════════════════════════════════════ -->
<h2 id="form-settings-title">Paramètres du Formulaire</h2>
<!-- ── Restrictions d'accès aux fichiers ── -->
<section aria-labelledby="form-restricted-files-title">
<h3 id="form-restricted-files-title">Restrictions d'accès aux fichiers</h3>
<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>
</section>
<!-- ── Degré d'ouverture ── -->
<section aria-labelledby="form-access-types-title">
<h3 id="form-access-types-title">Degré d'ouverture</h3>
<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>
</section>
<!-- ── Types de travaux ── -->
<section aria-labelledby="form-objet-types-title">
<h3 id="form-objet-types-title">Types de travaux</h3>
<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>
</section>
<!-- ═══════════════════════════════════════════════════════════════════
PAGES STATIQUES
═══════════════════════════════════════════════════════════════════ -->
<h2>Pages statiques</h2>
<table>
@@ -39,14 +149,13 @@
</tbody>
</table>
<!-- ═══════════════════════════════════════════════════════════════════
Structure du formulaire étudiant·e
═══════════════════════════════════════════════════════════════════ -->
<h2 id="form-help-blocks" style="margin-top:2rem;">Structure du formulaire étudiant·e</h2>
<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>
<!-- ── Structure du formulaire ── -->
<section aria-labelledby="form-help-blocks">
<h3 id="form-help-blocks">Structure du Formulaire</h3>
<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;
@@ -123,6 +232,7 @@
<?php endif; ?>
<?php endforeach; ?>
</div>
</section>
</main>

View File

@@ -106,6 +106,26 @@
// Languages — either from flash repopulation or current thesis data
$formData['languages'] = $formData['languages'] ?? $currentLanguages ?? [];
// Compute "other" languages (those not in the predefined checkbox list)
$predefinedLangIds = array_column($languages, 'id');
$otherLangIds = array_diff($currentLanguages ?? [], $predefinedLangIds);
$selectedOtherLanguages = [];
if (!empty($otherLangIds)) {
$allLangs = Database::getInstance()->getAllLanguages();
$allLangMap = [];
foreach ($allLangs as $al) {
$allLangMap[(int)$al['id']] = $al['name'];
}
foreach ($otherLangIds as $lid) {
$lid = (int)$lid;
if (isset($allLangMap[$lid])) {
$selectedOtherLanguages[] = $allLangMap[$lid];
}
}
// Sort alphabetically
sort($selectedOtherLanguages, SORT_NATURAL | SORT_FLAG_CASE);
}
// Tags — either from flash repopulation or current thesis data
$keywordsStr = $thesis['keywords'] ?? '';
$currentTags = $keywordsStr !== '' ? array_map('trim', explode(',', $keywordsStr)) : [];

View File

@@ -21,6 +21,10 @@ $sortArrow = function(string $col) use ($sortCol, $sortDir): string {
<div id="admin-table-wrap">
<!-- Hidden state for HTMX to preserve sort across filter changes -->
<input type="hidden" name="sort" value="<?= htmlspecialchars($sortCol) ?>">
<input type="hidden" name="dir" value="<?= htmlspecialchars($sortDir) ?>">
<!-- Meta bar: shows either nothing (default) or bulk actions on selection -->
<div id="bulk-actions" class="admin-bulk-actions" style="display:none">
<strong><span id="selected-count">0</span> TFE(s) sélectionné(s)</strong>
@@ -42,13 +46,13 @@ $sortArrow = function(string $col) use ($sortCol, $sortDir): string {
<thead>
<tr>
<th scope="col"><input type="checkbox" onchange="toggleAll(this)"></th>
<th scope="col"><a href="<?= $sortLink('identifier') ?>" class="admin-sort-link" hx-get="<?= htmlspecialchars($sortLink('identifier')) ?>" hx-target="#admin-table-wrap" hx-swap="innerHTML">ID<?= $sortArrow('identifier') ?></a></th>
<th scope="col"><a href="<?= $sortLink('title') ?>" class="admin-sort-link" hx-get="<?= htmlspecialchars($sortLink('title')) ?>" hx-target="#admin-table-wrap" hx-swap="innerHTML">Titre<?= $sortArrow('title') ?></a></th>
<th scope="col"><a href="<?= $sortLink('identifier') ?>" class="admin-sort-link" hx-get="<?= htmlspecialchars($sortLink('identifier')) ?>" hx-target="#admin-table-wrap" hx-swap="innerHTML" hx-indicator="#admin-search-indicator" hx-push-url="true">ID<?= $sortArrow('identifier') ?></a></th>
<th scope="col"><a href="<?= $sortLink('title') ?>" class="admin-sort-link" hx-get="<?= htmlspecialchars($sortLink('title')) ?>" hx-target="#admin-table-wrap" hx-swap="innerHTML" hx-indicator="#admin-search-indicator" hx-push-url="true">Titre<?= $sortArrow('title') ?></a></th>
<th scope="col">Auteur(s)</th>
<th scope="col"><a href="<?= $sortLink('year') ?>" class="admin-sort-link" hx-get="<?= htmlspecialchars($sortLink('year')) ?>" hx-target="#admin-table-wrap" hx-swap="innerHTML">Année<?= $sortArrow('year') ?></a></th>
<th scope="col"><a href="<?= $sortLink('orientation') ?>" class="admin-sort-link" hx-get="<?= htmlspecialchars($sortLink('orientation')) ?>" hx-target="#admin-table-wrap" hx-swap="innerHTML">Orientation<?= $sortArrow('orientation') ?></a></th>
<th scope="col"><a href="<?= $sortLink('ap_program') ?>" class="admin-sort-link" hx-get="<?= htmlspecialchars($sortLink('ap_program')) ?>" hx-target="#admin-table-wrap" hx-swap="innerHTML">AP<?= $sortArrow('ap_program') ?></a></th>
<th scope="col"><a href="<?= $sortLink('is_published') ?>" class="admin-sort-link" hx-get="<?= htmlspecialchars($sortLink('is_published')) ?>" hx-target="#admin-table-wrap" hx-swap="innerHTML">Publié<?= $sortArrow('is_published') ?></a></th>
<th scope="col"><a href="<?= $sortLink('year') ?>" class="admin-sort-link" hx-get="<?= htmlspecialchars($sortLink('year')) ?>" hx-target="#admin-table-wrap" hx-swap="innerHTML" hx-indicator="#admin-search-indicator" hx-push-url="true">Année<?= $sortArrow('year') ?></a></th>
<th scope="col"><a href="<?= $sortLink('orientation') ?>" class="admin-sort-link" hx-get="<?= htmlspecialchars($sortLink('orientation')) ?>" hx-target="#admin-table-wrap" hx-swap="innerHTML" hx-indicator="#admin-search-indicator" hx-push-url="true">Orientation<?= $sortArrow('orientation') ?></a></th>
<th scope="col"><a href="<?= $sortLink('ap_program') ?>" class="admin-sort-link" hx-get="<?= htmlspecialchars($sortLink('ap_program')) ?>" hx-target="#admin-table-wrap" hx-swap="innerHTML" hx-indicator="#admin-search-indicator" hx-push-url="true">AP<?= $sortArrow('ap_program') ?></a></th>
<th scope="col"><a href="<?= $sortLink('is_published') ?>" class="admin-sort-link" hx-get="<?= htmlspecialchars($sortLink('is_published')) ?>" hx-target="#admin-table-wrap" hx-swap="innerHTML" hx-indicator="#admin-search-indicator" hx-push-url="true">Publié<?= $sortArrow('is_published') ?></a></th>
<th scope="col">Accès</th>
<th scope="col">Actions</th>
</tr>
@@ -64,7 +68,7 @@ $sortArrow = function(string $col) use ($sortCol, $sortDir): string {
<td><input type="checkbox" name="selected_theses[]" value="<?= $thesis['id'] ?>"></td>
<td class="admin-table-id"><?= htmlspecialchars($thesis['identifier'] ?? $thesis['id']) ?></td>
<td>
<div class="thesis-title"><?= htmlspecialchars($thesis['title']) ?></div>
<div class="thesis-title" title="<?= htmlspecialchars($thesis['title']) ?>"><?= htmlspecialchars($thesis['title']) ?></div>
</td>
<td><?= htmlspecialchars($thesis['authors'] ?? 'N/A') ?></td>
<td><?= $thesis['year'] ?></td>

View File

@@ -43,7 +43,15 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
</div>
</div>
<form class="admin-filters" method="get" action="/admin/">
<form id="admin-filter-form" class="admin-filters" method="get" action="/admin/"
hx-get="/admin/"
hx-trigger="change from:select, input changed delay:300ms from:input[name=search], keyup[key=='Enter'] from:input[name=search]"
hx-target="#admin-table-wrap"
hx-swap="innerHTML"
hx-indicator="#admin-search-indicator"
hx-include="#admin-filter-form, #admin-table-wrap input[name=sort], #admin-table-wrap input[name=dir]"
hx-push-url="true"
hx-sync="#admin-filter-form:replace">
<input type="text" name="search" placeholder="Titre, auteur..."
value="<?= htmlspecialchars($searchQuery) ?>">
<select name="year">
@@ -68,12 +76,13 @@ document.addEventListener('htmx:afterSwap',()=>{document.querySelectorAll('input
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn--primary btn--sm admin-filters-btn">Filtrer</button>
<?php if ($searchQuery || $yearFilter || $orientationFilter || $apFilter): ?>
<button type="button" class="btn btn--secondary btn--sm admin-filters-reset"
onclick="window.location='/admin/'">&#x2715; Réinitialiser</button>
<a href="/admin/" class="btn btn--secondary btn--sm admin-filters-reset">&#x2715; Réinitialiser</a>
<?php endif; ?>
</form>
<!-- Loading indicator bar -->
<div id="admin-search-indicator" class="admin-search-indicator"></div>
</div>
<?php include APP_ROOT . '/templates/admin/index-table.php'; ?>

View File

@@ -31,129 +31,6 @@
</form>
<?php endif; ?>
</div>
<!-- Danger zone: delete all TFE → now inside maintenance -->
<fieldset class="param-danger-zone">
<legend>Supprimer tous les TFE</legend>
<p>
Supprime définitivement tous les TFE de la base de données, y compris auteurs,
promoteurs, tags, fichiers associés. Cette action est <strong>irréversible</strong>.
</p>
<form method="post" action="actions/delete.php" id="delete-all-tfe-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="delete_all" value="1">
<button type="button" class="btn btn--danger"
onclick="document.getElementById('delete-all-tfe-dialog').showModal()">
Supprimer tous les TFE (<?= $stats['total'] ?? '?' ?>)
</button>
</form>
</fieldset>
</section>
<!-- ══════════════════════════════════════════════════════════════
FORMULAIRE
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-formulaire-title">
<h2 id="settings-formulaire-title">Formulaire</h2>
<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">
<fieldset>
<legend>Types d'accès</legend>
<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>
</fieldset>
<fieldset>
<legend>Restriction d'accès aux fichiers</legend>
<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>
</fieldset>
<button type="submit" class="btn btn--primary">Enregistrer</button>
</form>
</section>
<!-- ══════════════════════════════════════════════════════════════
TYPES D'OBJET
══════════════════════════════════════════════════════════════ -->
<section aria-labelledby="settings-objet-title">
<h2 id="settings-objet-title">Types de travaux</h2>
<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">
<fieldset>
<legend>Types disponibles</legend>
<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>
</fieldset>
<button type="submit" class="btn btn--primary">Enregistrer</button>
</form>
</section>
<!-- ══════════════════════════════════════════════════════════════
@@ -662,21 +539,4 @@ document.body.addEventListener('htmx:afterSwap', function(evt) {
</div>
</dialog>
<!-- Delete all TFE confirm -->
<dialog id="delete-all-tfe-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="delete-all-title">
<div class="admin-dialog__header">
<h2 id="delete-all-title">Supprimer tous les TFE</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 définitivement <strong>TOUS les TFE</strong> ? Cette action est <strong>IRRÉVERSIBLE</strong>.</p>
</div>
<div class="admin-dialog__footer">
<button type="button" class="btn btn--danger"
onclick="this.closest('dialog').close(); document.getElementById('delete-all-tfe-form').submit()">
Supprimer tout
</button>
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div>
</dialog>