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

@@ -16,20 +16,14 @@ if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
exit;
}
$isBulk = !empty($_POST['bulk']);
$isDeleteAll = !empty($_POST['delete_all']);
$isBulk = !empty($_POST['bulk']);
try {
$db = new Database();
$logger = AdminLogger::make();
if ($isDeleteAll) {
$count = $db->deleteAllTheses();
$logger->logDeleteAllTheses($count);
App::flash('success', "$count TFE(s) supprimé(s) avec succès.");
} elseif ($isBulk) {
if ($isBulk) {
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
if (empty($ids)) {

View File

@@ -18,6 +18,7 @@ try {
$pages = array_values(array_filter($allPages, fn($p) => in_array($p['slug'], $allowedPageSlugs, true)));
$aproposKeys = $db->getAllAproposContents();
$formHelpBlocks = $db->getAllFormHelpBlocks();
$siteSettings = $db->getAllSettings();
} catch (Exception $e) {
error_log("Error loading contenus: " . $e->getMessage());
die("Erreur lors du chargement des contenus.");

View File

@@ -349,18 +349,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
}
}
if (!empty($languageRaw)) {
$langName = strtolower(trim($languageRaw));
// Lookup case-insensitively; insert if missing (stored lowercase).
$s = $importPdo->prepare("SELECT id FROM languages WHERE LOWER(name) = LOWER(?)");
$s->execute([$langName]);
$r = $s->fetch();
$langId = $r ? (int)$r['id'] : null;
if ($langId === null) {
$importPdo->prepare("INSERT INTO languages (name) VALUES (?)")->execute([$langName]);
$langId = (int)$importPdo->lastInsertId();
foreach (array_map('trim', explode(',', $languageRaw)) as $langName) {
$langName = strtolower($langName);
if ($langName === '') continue;
// Lookup case-insensitively; insert if missing (stored lowercase).
$s = $importPdo->prepare("SELECT id FROM languages WHERE LOWER(name) = LOWER(?)");
$s->execute([$langName]);
$r = $s->fetch();
$langId = $r ? (int)$r['id'] : null;
if ($langId === null) {
$importPdo->prepare("INSERT INTO languages (name) VALUES (?)")->execute([$langName]);
$langId = (int)$importPdo->lastInsertId();
}
$s2 = $importPdo->prepare("INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)");
$s2->execute([$thesisId, $langId]);
}
$s2 = $importPdo->prepare("INSERT INTO thesis_languages (thesis_id, language_id) VALUES (?,?)");
$s2->execute([$thesisId, $langId]);
}
if (!empty($formatsRaw)) {
foreach (array_map('trim', explode(',', $formatsRaw)) as $fmt) {

View File

@@ -0,0 +1,13 @@
<?php
/**
* language-search-fragment.php (admin)
*
* HTMX fragment: returns matching language suggestions for the
* "Autre(s) langue(s)" interactive search input. Admin-auth gated wrapper.
*/
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once APP_ROOT . '/public/partage/language-search-fragment.php';

View File

@@ -16,7 +16,6 @@ $siteSettings = $db->getAllSettings();
$peerTubeSettings = PeerTubeService::getSettings($db);
$peerTubeEnabled = PeerTubeService::isEnabled($db);
$peerTubeConfigured = PeerTubeService::isConfigured($db);
$stats = $db->getThesesStats();
$smtpSettings = SmtpRelay::getSettings($db);
$smtpConfigured = SmtpRelay::isConfigured($db);
$smtpErrorField = $_SESSION['_flash_smtp_field'] ?? null;

View File

@@ -379,6 +379,11 @@ th.admin-ap-col {
.admin-body table .thesis-title {
font-weight: 500;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.admin-body table .thesis-subtitle {
@@ -1943,3 +1948,26 @@ th.admin-ap-col {
margin: 0;
}
}
/* ── Active search loading indicator ───────────────────────────────────── */
.admin-search-indicator {
display: block;
height: 2px;
background: var(--accent-primary);
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
margin-top: var(--space-2xs);
}
.admin-search-indicator.htmx-request {
opacity: 1;
animation: admin-search-progress 1.2s ease-in-out infinite;
}
@keyframes admin-search-progress {
0% { transform: scaleX(0); transform-origin: left; }
50% { transform: scaleX(1); transform-origin: left; }
50.01% { transform: scaleX(1); transform-origin: right; }
100% { transform: scaleX(0); transform-origin: right; }
}

View File

@@ -56,6 +56,13 @@ if ($slug === 'tag-search-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST') {
exit;
}
// Special route: /partage/language-search-fragment (HTMX fragment — interactive language search)
if ($slug === 'language-search-fragment' && $_SERVER['REQUEST_METHOD'] === 'POST') {
App::boot();
require_once __DIR__ . '/language-search-fragment.php';
exit;
}
// Special route: /partage/recapitulatif?id=N
if ($slug === 'recapitulatif' || $slug === 'recapitulatif.php') {
App::boot();

View File

@@ -2,9 +2,9 @@
/**
* language-autre-fragment.php
*
* Shared HTMX fragment include: returns the "Autre(s) langue(s)" input row
* when no standard language checkbox is checked, or the plain (non-required)
* variant when at least one is checked.
* Shared HTMX fragment include: returns the requirement asterisk for the
* "Autre(s) langue(s)" label. When no standard language checkbox is checked,
* an asterisk is rendered to signal the field is required (server-side validated).
*
* Included by:
* - /admin/language-autre-fragment.php (AdminAuth gated)
@@ -12,25 +12,11 @@
*
* Expected POST:
* languages[] — selected language IDs (may be absent)
* language_autre — current free-text value (for repopulation)
*/
$selectedIds = isset($_POST['languages']) && is_array($_POST['languages'])
? $_POST['languages']
: [];
$currentValue = htmlspecialchars(trim($_POST['language_autre'] ?? ''));
$anyChecked = !empty($selectedIds);
?>
<div id="language-autre-row">
<div>
<label for="language_autre">Autre(s) langue(s) :<?= !$anyChecked ? ' <span class="asterisk">*</span>' : '' ?></label>
<div>
<input type="text"
id="language_autre"
name="language_autre"
value="<?= $currentValue ?>"
<?= !$anyChecked ? 'required' : '' ?>>
<small>Si votre TFE contient une langue absente de la liste, précisez-la ici.</small>
</div>
</div>
</div>
<span id="language-autre-required"><?= !$anyChecked ? ' <span class="asterisk">*</span>' : '' ?></span>

View File

@@ -0,0 +1,107 @@
<?php
/**
* language-search-fragment.php
*
* Shared HTMX fragment: returns matching language suggestions for the
* "Autre(s) langue(s)" interactive search input.
*
* Included by:
* - /admin/language-search-fragment.php (AdminAuth gated)
* - partage/index.php special route (public, session already booted)
*
* Expected POST:
* q — search query string (partial language name)
* language_autre[] — already selected language names (for exclusion)
*/
require_once __DIR__ . '/../../src/Database.php';
$query = trim(preg_replace('/\s+/', ' ', strtolower($_POST['q'] ?? '')));
$currentLanguages = isset($_POST['language_autre']) && is_array($_POST['language_autre'])
? array_map(function($l) { return trim(preg_replace('/\s+/', ' ', strtolower($l))); }, $_POST['language_autre'])
: [];
$db = Database::getInstance();
// Search existing languages by name, excluding predefined ones (already shown as checkboxes)
$predefined = ["français", "anglais", "néerlandais", "francais", "neerlandais"];
if ($query !== '') {
$placeholders = implode(',', array_fill(0, count($predefined), '?'));
$stmt = $db->getConnection()->prepare(
"SELECT l.id, UPPER(SUBSTR(l.name,1,1)) || SUBSTR(l.name,2) as name,
COUNT(DISTINCT tl.thesis_id) as thesis_count
FROM languages l
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
WHERE LOWER(l.name) LIKE ?
AND LOWER(l.name) NOT IN ($placeholders)
GROUP BY l.id
ORDER BY LOWER(l.name) = ? DESC, thesis_count DESC, LOWER(l.name)
LIMIT 10"
);
$stmt->execute(array_merge([$query . '%'], $predefined, [$query]));
} else {
$placeholders = implode(',', array_fill(0, count($predefined), '?'));
$stmt = $db->getConnection()->prepare(
"SELECT l.id, UPPER(SUBSTR(l.name,1,1)) || SUBSTR(l.name,2) as name,
COUNT(DISTINCT tl.thesis_id) as thesis_count
FROM languages l
LEFT JOIN thesis_languages tl ON l.id = tl.language_id
WHERE LOWER(l.name) NOT IN ($placeholders)
GROUP BY l.id
ORDER BY thesis_count DESC, LOWER(l.name)
LIMIT 10"
);
$stmt->execute($predefined);
}
$results = $stmt->fetchAll();
// Deduplicate results by lowercase name
$seen = [];
$results = array_values(array_filter($results, function($lang) use (&$seen) {
$key = strtolower($lang['name']);
if (isset($seen[$key])) return false;
$seen[$key] = true;
return true;
}));
// Filter out already-selected languages (case-insensitive)
$results = array_values(array_filter($results, function($lang) use ($currentLanguages) {
return !in_array(strtolower($lang['name']), $currentLanguages, true);
}));
// Check if query exactly matches an existing language (case-insensitive)
// Also check against predefined languages to avoid suggesting creation of a checkbox language
$exactExists = false;
foreach ($results as $lang) {
if (strcasecmp($lang['name'], $query) === 0) {
$exactExists = true;
break;
}
}
if (!$exactExists && $query !== '') {
$normalisedQuery = strtolower($query);
$normalisedPredefined = array_map('strtolower', $predefined);
if (in_array($normalisedQuery, $normalisedPredefined, true)) {
$exactExists = true;
}
}
// If no exact match and query non-empty, suggest creation
$canCreate = ($query !== '' && !$exactExists && !in_array($query, $currentLanguages, true));
?>
<?php if (empty($results) && !$canCreate): ?>
<div class="tag-search-empty">Aucune langue trouvée.</div>
<?php endif; ?>
<?php foreach ($results as $lang): ?>
<button type="button" class="tag-search-item" data-tag-id="<?= (int)$lang['id'] ?>" data-tag-name="<?= htmlspecialchars($lang['name']) ?>">
<span class="tag-search-item-name"><?= htmlspecialchars($lang['name']) ?></span>
<span class="tag-search-item-count">(<?= (int)$lang['thesis_count'] ?>)</span>
</button>
<?php endforeach; ?>
<?php if ($canCreate): ?>
<button type="button" class="tag-search-item tag-search-item--create" data-tag-name="<?= htmlspecialchars($query) ?>">
<span class="tag-search-item-name">Créer «&nbsp;<?= htmlspecialchars($query) ?>&nbsp;»</span>
</button>
<?php endif; ?>