mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
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:
@@ -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)) {
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
13
app/public/admin/language-search-fragment.php
Normal file
13
app/public/admin/language-search-fragment.php
Normal 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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
app/public/partage/language-search-fragment.php
Normal file
107
app/public/partage/language-search-fragment.php
Normal 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 « <?= htmlspecialchars($query) ?> »</span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
@@ -194,12 +194,6 @@ class AdminLogger
|
||||
]);
|
||||
}
|
||||
|
||||
/** Parametres: delete all TFEs */
|
||||
public function logDeleteAllTheses(int $count): void
|
||||
{
|
||||
$this->write('system', 'delete_all_theses', 'success', ['count' => $count]);
|
||||
}
|
||||
|
||||
/** Parametres: formulaire section toggles */
|
||||
public function logFormSettingsUpdate(array $newValues): void
|
||||
{
|
||||
|
||||
@@ -114,7 +114,7 @@ class ThesisCreateController
|
||||
'orientations' => $this->db->getAllOrientations(),
|
||||
'apPrograms' => $this->db->getAllAPPrograms(),
|
||||
'finalityTypes' => $this->db->getAllFinalityTypes(),
|
||||
'languages' => $this->db->getAllLanguages(),
|
||||
'languages' => $this->db->getPredefinedLanguages(),
|
||||
'formatTypes' => $this->db->getAllFormatTypes(),
|
||||
'licenseTypes' => $this->db->getAllLicenseTypes(),
|
||||
'enabledAccessTypes' => $this->db->getEnabledFormAccessTypes(),
|
||||
@@ -467,8 +467,16 @@ class ThesisCreateController
|
||||
$languageIds = isset($post['languages']) && is_array($post['languages'])
|
||||
? array_map('intval', $post['languages'])
|
||||
: [];
|
||||
$autreRaw = trim($post['language_autre'] ?? '');
|
||||
if ($autreRaw !== '') {
|
||||
// language_autre: pill-based component sends an array; also handle legacy comma-separated string
|
||||
$autreRaw = $post['language_autre'] ?? '';
|
||||
if (is_array($autreRaw)) {
|
||||
foreach ($autreRaw as $langName) {
|
||||
$langName = trim($langName);
|
||||
if ($langName !== '') {
|
||||
$languageIds[] = $this->db->getOrCreateLanguage($langName);
|
||||
}
|
||||
}
|
||||
} elseif (is_string($autreRaw) && trim($autreRaw) !== '') {
|
||||
foreach (array_map('trim', explode(',', $autreRaw)) as $langName) {
|
||||
if ($langName !== '') {
|
||||
$languageIds[] = $this->db->getOrCreateLanguage($langName);
|
||||
|
||||
@@ -95,7 +95,7 @@ class ThesisEditController
|
||||
$orientations = $this->db->getAllOrientations();
|
||||
$apPrograms = $this->db->getAllAPPrograms();
|
||||
$finalityTypes = $this->db->getAllFinalityTypes();
|
||||
$languages = $this->db->getAllLanguages();
|
||||
$languages = $this->db->getPredefinedLanguages();
|
||||
$formatTypes = $this->db->getAllFormatTypes();
|
||||
$licenseTypes = $this->db->getAllLicenseTypes();
|
||||
$enabledAccessTypes = $this->db->getEnabledFormAccessTypes();
|
||||
@@ -247,8 +247,16 @@ class ThesisEditController
|
||||
$langIds = isset($post['languages']) && is_array($post['languages'])
|
||||
? $post['languages']
|
||||
: [];
|
||||
$autreRaw = trim($post['language_autre'] ?? '');
|
||||
if ($autreRaw !== '') {
|
||||
// language_autre: pill-based component sends an array; also handle legacy comma-separated string
|
||||
$autreRaw = $post['language_autre'] ?? '';
|
||||
if (is_array($autreRaw)) {
|
||||
foreach ($autreRaw as $langName) {
|
||||
$langName = trim($langName);
|
||||
if ($langName !== '') {
|
||||
$langIds[] = (string)$this->db->getOrCreateLanguage($langName);
|
||||
}
|
||||
}
|
||||
} elseif (is_string($autreRaw) && trim($autreRaw) !== '') {
|
||||
foreach (array_map('trim', explode(',', $autreRaw)) as $langName) {
|
||||
if ($langName !== '') {
|
||||
$langIds[] = (string)$this->db->getOrCreateLanguage($langName);
|
||||
|
||||
@@ -752,6 +752,21 @@ class Database
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only the predefined / hardcoded languages used as checkboxes
|
||||
* in the form. All other languages go into the "Autre langue" input.
|
||||
*/
|
||||
public function getPredefinedLanguages(): array
|
||||
{
|
||||
$stmt = $this->pdo->query(
|
||||
"SELECT id, UPPER(SUBSTR(name,1,1)) || SUBSTR(name,2) as name, created_at
|
||||
FROM languages
|
||||
WHERE LOWER(name) IN ('français', 'anglais', 'néerlandais', 'francais', 'neerlandais')
|
||||
ORDER BY name"
|
||||
);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ADMIN LIST METHOD
|
||||
// ========================================================================
|
||||
@@ -1940,17 +1955,6 @@ class Database
|
||||
/**
|
||||
* Delete every thesis in the database.
|
||||
*/
|
||||
public function deleteAllTheses(): int
|
||||
{
|
||||
$ids = $this->pdo->query('SELECT id FROM theses')->fetchAll(\PDO::FETCH_COLUMN);
|
||||
if (empty($ids)) {
|
||||
return 0;
|
||||
}
|
||||
$count = count($ids);
|
||||
$this->bulkDeleteTheses($ids);
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a thesis file record.
|
||||
* sort_order defaults to (max existing sort_order + 1) for the thesis.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)) : [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/'">✕ Réinitialiser</button>
|
||||
<a href="/admin/" class="btn btn--secondary btn--sm admin-filters-reset">✕ 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'; ?>
|
||||
|
||||
@@ -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()">✕</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>
|
||||
|
||||
|
||||
@@ -186,29 +186,45 @@ $checkedFormatsForSiteWeb = $checkedFormatsForSiteWeb ?? [];
|
||||
$checked = $formData["languages"] ?? [];
|
||||
$required = !$adminMode;
|
||||
$hxPost = $mode === 'partage' ? "/partage/language-autre-fragment" : "/admin/language-autre-fragment.php";
|
||||
$hxTarget = "#language-autre-row";
|
||||
$hxTarget = "#language-autre-required";
|
||||
$hxSwap = "outerHTML";
|
||||
include APP_ROOT . "/templates/partials/form/checkbox-list.php";
|
||||
unset($hxSwap);
|
||||
?>
|
||||
<?php
|
||||
$_langAutreRequired = empty($formData["languages"]);
|
||||
$_langAutreValue = $oldFn("language_autre");
|
||||
// Build selectedLanguages array from form data or current other languages
|
||||
$_selectedOtherLangs = [];
|
||||
if (!empty($formData['language_autre']) && is_array($formData['language_autre'])) {
|
||||
foreach ($formData['language_autre'] as $_l) {
|
||||
if (is_string($_l) && trim($_l) !== '') {
|
||||
$_selectedOtherLangs[] = ['name' => trim($_l)];
|
||||
}
|
||||
}
|
||||
} elseif (!empty($selectedOtherLanguages) && is_array($selectedOtherLanguages)) {
|
||||
$_selectedOtherLangs = array_map(fn($n) => ['name' => $n], $selectedOtherLanguages);
|
||||
} else {
|
||||
$_langRaw = $formData["language_autre"] ?? '';
|
||||
if (is_string($_langRaw) && $_langRaw !== '') {
|
||||
foreach (array_map('trim', explode(',', $_langRaw)) as $_l) {
|
||||
if ($_l !== '') {
|
||||
$_selectedOtherLangs[] = ['name' => $_l];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<?php
|
||||
$name = "language_autre";
|
||||
$label = "Autre(s) langue(s) :";
|
||||
$placeholder = "Rechercher une langue…";
|
||||
$hint = "Si votre TFE contient une langue absente de la liste, précisez-la ici.";
|
||||
$selectedLanguages = $_selectedOtherLangs;
|
||||
$required = $_langAutreRequired && !$adminMode;
|
||||
$hxPost = ($mode === 'partage') ? "/partage/language-search-fragment" : "/admin/language-search-fragment.php";
|
||||
include APP_ROOT . "/templates/partials/form/language-search.php";
|
||||
unset($_langAutreRequired, $_selectedOtherLangs, $_langRaw, $_l, $name, $label, $placeholder, $hint, $selectedLanguages, $required, $hxPost);
|
||||
?>
|
||||
<div id="language-autre-row">
|
||||
<div>
|
||||
<label for="language_autre">Autre(s) langue(s) :<?= $_langAutreRequired ? ' <span class="asterisk">*</span>' : '' ?></label>
|
||||
<div>
|
||||
<input type="text"
|
||||
id="language_autre"
|
||||
name="language_autre"
|
||||
value="<?= $_langAutreValue ?>"
|
||||
<?= (!$adminMode && $_langAutreRequired) ? 'required' : '' ?>>
|
||||
<small>Si votre TFE contient une langue absente de la liste, précisez-la ici.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php unset($_langAutreRequired, $_langAutreValue); ?>
|
||||
</fieldset>
|
||||
|
||||
<!-- ═══════════════════ Mots-clés ═══════════════════ -->
|
||||
|
||||
242
app/templates/partials/form/language-search.php
Normal file
242
app/templates/partials/form/language-search.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
/**
|
||||
* Language search partial — interactive "Autre(s) langue(s)" input with HTMX-powered suggestions.
|
||||
*
|
||||
* Replaces the old plain text input with an interactive component:
|
||||
* - Type to search among existing languages via HTMX
|
||||
* - If the language doesn't exist, a "Créer" option appears
|
||||
* - Selected languages are shown as pills with a round delete button (bin icon)
|
||||
* - All language names are lowercased and deduplicated
|
||||
*
|
||||
* Variables consumed:
|
||||
* string $name — base input name (hidden inputs will be name[]); default 'language_autre'
|
||||
* string $label — visible label text
|
||||
* string $placeholder — placeholder text for the search input
|
||||
* string $hint — optional hint shown above the input
|
||||
* string $hxPost — HTMX POST endpoint for language search
|
||||
* array $selectedLanguages — array of ['id' => int|null, 'name' => string] for pre-filled languages
|
||||
* string|null $id — override the id attribute prefix
|
||||
* int $maxLanguages — maximum number of languages (default 10)
|
||||
* bool $required — whether at least one "other language" is required (default false)
|
||||
*/
|
||||
|
||||
$name = $name ?? 'language_autre';
|
||||
$label = $label ?? 'Autre(s) langue(s)';
|
||||
$placeholder = $placeholder ?? 'Rechercher une langue…';
|
||||
$hint = $hint ?? null;
|
||||
$hxPost = $hxPost ?? '/admin/language-search-fragment.php';
|
||||
$selectedLanguages = $selectedLanguages ?? [];
|
||||
$id = $id ?? $name;
|
||||
$maxLanguages = $maxLanguages ?? 10;
|
||||
$required = $required ?? false;
|
||||
$langCount = count($selectedLanguages);
|
||||
?>
|
||||
<div id="<?= htmlspecialchars($id) ?>-search-container">
|
||||
<span class="admin-row-label"><?= htmlspecialchars($label) ?><span id="language-autre-required"><?= $required ? ' <span class="asterisk">*</span>' : '' ?></span></span>
|
||||
<div class="tag-search-wrapper">
|
||||
<?php if ($hint): ?>
|
||||
<small class="tag-search-hint"><?= htmlspecialchars($hint) ?></small>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Active language pills -->
|
||||
<div class="tag-search-pills" id="<?= htmlspecialchars($id) ?>-pills">
|
||||
<?php foreach ($selectedLanguages as $lang): ?>
|
||||
<span class="tag-pill">
|
||||
<input type="hidden" name="<?= htmlspecialchars($name) ?>[]" value="<?= htmlspecialchars($lang['name']) ?>">
|
||||
<span class="tag-pill-name"><?= htmlspecialchars($lang['name']) ?></span>
|
||||
<button type="button" class="tag-pill-remove" title="Retirer « <?= htmlspecialchars($lang['name']) ?> »" aria-label="Retirer <?= htmlspecialchars($lang['name']) ?>">
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM112,168V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Zm48,0V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Z"></path></svg>
|
||||
</button>
|
||||
</span>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Counter visible only when languages exist or max reached -->
|
||||
<div class="tag-search-counter" id="<?= htmlspecialchars($id) ?>-counter"<?= $langCount === 0 ? ' style="display:none"' : '' ?>>
|
||||
<span class="tag-search-count" id="<?= htmlspecialchars($id) ?>-count"><?= $langCount ?>/<?= (int)$maxLanguages ?></span>
|
||||
<?php if ($langCount >= $maxLanguages): ?>
|
||||
<span class="tag-search-max-msg">Maximum de langues atteint</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Search input (hidden when max languages reached) -->
|
||||
<div class="tag-search-input-wrap"<?= $langCount >= $maxLanguages ? ' style="display:none"' : '' ?>>
|
||||
<input type="text"
|
||||
name="q"
|
||||
id="<?= htmlspecialchars($id) ?>-search"
|
||||
class="tag-search-input"
|
||||
placeholder="<?= htmlspecialchars($placeholder) ?>"
|
||||
autocomplete="off"
|
||||
hx-post="<?= htmlspecialchars($hxPost) ?>"
|
||||
hx-trigger="input changed delay:200ms, focus"
|
||||
hx-target="#<?= htmlspecialchars($id) ?>-suggestions"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#<?= htmlspecialchars($id) ?>-pills">
|
||||
</div>
|
||||
|
||||
<!-- Suggestions dropdown (positioned absolutely over content) -->
|
||||
<div class="tag-search-suggestions" id="<?= htmlspecialchars($id) ?>-suggestions" role="listbox"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline script for the interactive behaviour (no external JS required) -->
|
||||
<script>
|
||||
(function() {
|
||||
const container = document.getElementById(<?= json_encode($id . '-search-container') ?>);
|
||||
if (!container || container._langSearchInit) return;
|
||||
container._langSearchInit = true;
|
||||
|
||||
const pills = document.getElementById(<?= json_encode($id . '-pills') ?>);
|
||||
const search = document.getElementById(<?= json_encode($id . '-search') ?>);
|
||||
const dropdown = document.getElementById(<?= json_encode($id . '-suggestions') ?>);
|
||||
const countEl = document.getElementById(<?= json_encode($id . '-count') ?>);
|
||||
const counter = document.getElementById(<?= json_encode($id . '-counter') ?>);
|
||||
const maxLanguages = <?= (int)$maxLanguages ?>;
|
||||
const inputName = <?= json_encode($name) ?>;
|
||||
let selectedIdx = -1;
|
||||
|
||||
function updateCount() {
|
||||
const n = pills.querySelectorAll('.tag-pill').length;
|
||||
if (countEl) countEl.textContent = n + '/' + maxLanguages;
|
||||
if (counter) counter.style.display = (n > 0) ? '' : 'none';
|
||||
|
||||
// Show/hide search input based on max
|
||||
const wrap = container.querySelector('.tag-search-input-wrap');
|
||||
const maxMsg = container.querySelector('.tag-search-max-msg');
|
||||
if (n >= maxLanguages) {
|
||||
if (wrap) wrap.style.display = 'none';
|
||||
if (maxMsg) maxMsg.style.display = '';
|
||||
} else {
|
||||
if (wrap) {
|
||||
wrap.style.display = '';
|
||||
if (search) search.style.display = '';
|
||||
}
|
||||
if (maxMsg) maxMsg.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Lowercase, collapse spaces, trim, ucfirst for display
|
||||
function normalizeLang(name) {
|
||||
return name.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
function ucfirst(str) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
// Check if language already exists in pills (case-insensitive)
|
||||
function langAlreadyAdded(name) {
|
||||
const norm = normalizeLang(name);
|
||||
const existing = pills.querySelectorAll('.tag-pill-name');
|
||||
for (const el of existing) {
|
||||
if (normalizeLang(el.textContent) === norm) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove a pill
|
||||
pills.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('.tag-pill-remove');
|
||||
if (!btn) return;
|
||||
const pill = btn.closest('.tag-pill');
|
||||
pill.remove();
|
||||
updateCount();
|
||||
// Re-enable search field visibility
|
||||
const wrap = container.querySelector('.tag-search-input-wrap');
|
||||
const searchInput = container.querySelector('.tag-search-input');
|
||||
if (wrap && searchInput) {
|
||||
wrap.style.display = '';
|
||||
searchInput.style.display = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Highlight a suggestion by index
|
||||
function highlight(idx) {
|
||||
const items = dropdown.querySelectorAll('.tag-search-item');
|
||||
items.forEach(function(item, i) {
|
||||
if (i === idx) {
|
||||
item.classList.add('tag-search-item--highlight');
|
||||
} else {
|
||||
item.classList.remove('tag-search-item--highlight');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Select a suggestion by button element
|
||||
function selectLang(btn) {
|
||||
const langName = normalizeLang(btn.getAttribute('data-tag-name') || '');
|
||||
if (!langName) return;
|
||||
|
||||
if (langAlreadyAdded(langName)) return;
|
||||
if (pills.querySelectorAll('.tag-pill').length >= maxLanguages) return;
|
||||
|
||||
const escapedName = htmlEscape(langName);
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'tag-pill';
|
||||
pill.innerHTML = '<input type="hidden" name="' + inputName + '[]" value="' + escapedName + '">'
|
||||
+ '<span class="tag-pill-name">' + escapedName + '</span>'
|
||||
+ '<button type="button" class="tag-pill-remove" title="Retirer \u00AB\u00A0' + escapedName + '\u00A0\u00BB" aria-label="Retirer ' + escapedName + '">'
|
||||
+ '<svg width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM112,168V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Zm48,0V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Z"></path></svg>'
|
||||
+ '</button>';
|
||||
pills.appendChild(pill);
|
||||
updateCount();
|
||||
search.value = '';
|
||||
dropdown.innerHTML = '';
|
||||
selectedIdx = -1;
|
||||
search.focus();
|
||||
}
|
||||
|
||||
// Click on suggestion
|
||||
dropdown.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('.tag-search-item');
|
||||
if (!btn) return;
|
||||
selectLang(btn);
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
search.addEventListener('keydown', function(e) {
|
||||
const items = dropdown.querySelectorAll('.tag-search-item');
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (items.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
selectedIdx = (selectedIdx + 1) % items.length;
|
||||
} else {
|
||||
selectedIdx = selectedIdx <= 0 ? items.length - 1 : selectedIdx - 1;
|
||||
}
|
||||
highlight(selectedIdx);
|
||||
} else if (e.key === 'Enter') {
|
||||
if (items.length > 0) {
|
||||
e.preventDefault();
|
||||
if (selectedIdx >= 0 && selectedIdx < items.length) {
|
||||
selectLang(items[selectedIdx]);
|
||||
} else {
|
||||
selectLang(items[0]);
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
dropdown.innerHTML = '';
|
||||
selectedIdx = -1;
|
||||
}
|
||||
});
|
||||
|
||||
// Hide dropdown on blur (after a tiny delay so click events fire)
|
||||
search.addEventListener('blur', function() {
|
||||
setTimeout(function() {
|
||||
if (!dropdown.contains(document.activeElement)) {
|
||||
dropdown.innerHTML = '';
|
||||
selectedIdx = -1;
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
|
||||
function htmlEscape(str) {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = str;
|
||||
return el.innerHTML;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php
|
||||
unset($name, $label, $placeholder, $hint, $hxPost, $selectedLanguages, $id, $maxLanguages, $langCount, $required);
|
||||
Reference in New Issue
Block a user