Add Mots-clés and Langues management to contenus page

- Add searchLanguages, getAllLanguagesWithCount, renameLanguage, mergeLanguage, deleteLanguage to Database
- Create actions/language.php handler with rename/merge/merge_bulk/delete actions
- Add merge_bulk action to actions/tag.php
- Add Mots-clés section to contenus template with HTMX search, select checkboxes, rename/delete/merge buttons, and multi-select merge toolbar
- Add Langues section to contenus template with same pattern
- Create contenus-tags-fragment.php and contenus-languages-fragment.php HTMX fragments
- Remove form-settings- from flat-fieldset CSS selector so fieldsets in contenus retain border/padding
- contenus.php: add 'Gérer les mots-clés' link to /admin/tags.php
- contenus.php: add Langues fieldset with HTMX search + table (rename/merge/delete/bulk)
- tags.php: add HTMX search bar, checkbox column, bulk merge toolbar
- Create tags-fragment.php and contenus-langues-fragment.php for HTMX
- Remove tab component and associated CSS
- Simplify JS: separate tags/langues-prefixed functions
- Fix redirects: tag.php defaults to /admin/tags.php, supports return override
- Keep tags.php standalone page and Mots-clés button unchanged
This commit is contained in:
Pontoporeia
2026-05-10 12:13:26 +02:00
parent 494675d78c
commit 396cf19e9f
13 changed files with 814 additions and 195 deletions

91
TODO.md
View File

@@ -1,88 +1,7 @@
# TODO # TODO
- [x] Remove delete-all TFE from parametres (template, dialog, controller, DB method, logger) - [x] Rename "Éditer Données Secondaires" → "Données Secondaires", remove wrapping fieldset on Mots-clés
- [x] Move Formulaire + Types de travaux from parametres to contenus under Paramètres du Formulaire h2 - [x] Create admin-toc.php sidebar TOC partial with IntersectionObserver
- [x] Restructure contenus Formulaire: sub-headings for Restrictions, Degré d'ouverture, Types de travaux, Structure - [x] Include TOC in contenus.php, acces.php, parametres.php
- [x] Copy mots-clé htmx system (dropdown, pills, create) to Autre Langue input - [x] Add .admin-with-toc flex layout and .admin-toc CSS
- [x] Languages: store lowercase, display with ucfirst (getOrCreateLanguage, CSV import, getAllLanguages, v_theses_full, schema seed data, migration 025) - [x] Fonts: verified Ductus + BBB DM Sans are loaded via variables.css → common.css
- [x] CSV importer: add AP aliases for D&P du multiple, PACS variants, Narraion typo
- [x] Move default semantic form element styles (checkbox, radio, select) from admin.css/form.css into common.css
- [x] Keep specific layouts/classes in form.css (admin-form grid, checkbox-group layout, etc.)
- [x] Ensure selects, checkboxes, and radios are properly styled globally
- [x] Converge towards the styled form appearance rather than unstyled
- [x] Fix: replace mb_strlen/mb_substr/mb_strtolower with strlen/substr/strtolower (mbstring extension missing on server, caused fatal error on partage submit at ThesisCreateController line 511)
- [x] Fix: annexes checkbox in partage form clears other file inputs — scoped HTMX swap to #annexes-input-block instead of replacing entire #format-fichiers-block
- [x] Fix: website/video/audio inputs should be inline in Fichiers fieldset (not sub-fieldsets) — removed <fieldset class="fichiers-format-extra"> wrappers
- [x] Fix: video/audio show direct upload input when PeerTube disabled — parallel inputs: PeerTube upload when enabled, direct `files[]` upload when disabled
- [x] Fix: format checkboxes HTMX include missing has_annexes — added it so annexes state preserved across format changes
- [x] Fix: format checkbox toggle clears file inputs — split into two blocks: #format-fichiers-block (stable: TFE/annexes/couverture/note) and #format-extras-block (swappable: website/video/audio extras)
- [x] Fix: remove website label/legend input — website section now shows only URL field
- [x] Fix: format-extras not appearing — moved #format-extras-block inside Fichiers fieldset (after annexes), uses hx-select to extract from response
- [x] Remove duration_pages, duration_minutes, file_size_info entirely (form, schema, DB, views, controllers, tests, CSV export, email)
- [x] Rename cc4r → cc2r everywhere (DB column, schema, PHP code) to fix pre-existing naming inconsistency
- [x] Merge Publication fieldset's is_published checkbox into Backoffice fieldset
- [x] Fix: PHP parse error in admin/index.php — `''` escape in single-quoted string not valid in PHP 8.5
- [x] Add explanation hint to is_published checkbox
- [x] Admin index: use AP code instead of full name in list and filter dropdown
- [x] Admin index: remove pagination, show all theses in table
- [x] Admin index: HTMX column sorting (click header → reload table via HTMX)
- [x] Admin index: prevent action buttons from stacking vertically
- [x] Admin index: compact icon-only buttons (SVG) with tooltips replacing text labels
- [x] Admin index: reduce status badge font size
- [x] Admin index: change Voir icon to spectacles/circles SVG
- [x] Admin index: split Statut column into Publié and Accès
- [x] Admin index: tighten table cell padding to --space-3xs
- [x] Admin index: remove main padding, add padding to .admin-list-toolbar and #admin-table-wrap
- [x] Admin index: remove subtitles from Titre column
- [x] Admin index: add alternating row background colors
- [x] Admin index: remove #admin-table-container wrapper element, use #admin-table-wrap
- [x] Admin index: rearrange toolbar — stats beside title, buttons in single row, search inline with selects on right
- [x] Admin index: fix toolbar search inputs vertical stacking (add flex-direction: row)
- [x] Admin index: stats as fieldsets with legend labels (Total/Publiés/Attente), centered content
- [x] Admin index: remove horizontal padding from toolbar and table-wrap (keep bottom padding only)
- [x] Admin index: make Filtrer/Réinitialiser buttons same size as inputs (add btn--sm)
- [x] Admin index: rename Importer un CSV → Importer, merge Export CSV + Export fichiers → Exporter modal with checkboxes
- [x] Create unified /admin/actions/export.php endpoint with ?csv=1&files=1&db=1 support
- [x] Admin index: move export DB from parametres into exporter modal
- [x] Admin index: color stats — green for Publiés, yellow for Attente
- [x] Remove export-db fieldset and dialog from parametres.php
- [x] Replace large JS script in admin index with minimal version (8 lines vs ~70)
- [x] Bulk actions: form wraps all checkboxes, no dynamic DOM building in JS
- [x] Replace emoji/text buttons in acces.php/acces-etudiante.php with Phosphor SVG icon buttons
- [x] Replace text button in contenus.php with pencil SVG icon button
- [x] Add Phosphor Icons credit to about page
- [x] Add back-to-list arrow button to add/edit/recapitulatif/contenus-edit/tags page titles, make bigger (32px)
- [x] Remove Voir button from admin index — row click navigates to recapitulatif
- [x] Add hover highlight on clickable table rows
- [x] AP column no-wrap, N/A values greyed
- [x] Tags page: back button, admin-main--list, no padding, icon buttons, #admin-table-wrap
- [x] Move #bulk-actions into fixed-height #bulk-meta-bar at top, prevent layout shift
- [x] Credits: move Iconographie below Typographies
- [x] Rename Accès étudiant·e → Liens étudiant·e
- [x] Add 'name' column to share_links (schema + migration + model)
- [x] Add edit dialog for share links (edit name, password, expiration)
- [x] Row click opens link in new tab, remove Visiter button
- [x] Add update action to acces-etudiante.php controller
- [x] Add ShareLink::update() method
- [x] Remove admin-bulk-meta__default (count bar), clean up layout
- [x] Fix nested form issue: per-row publish/unpublish buttons now submit correctly
- [x] Fix delete button: stopPropagation prevents row nav on confirm
- [x] CSV import: set is_published=0 by default instead of 1
- [x] Fix AP column wrapping: CSS selector main > table didn't match (table nested in div)
- [x] Admin mobile block screen: fix inline style beating media query, use CSS default display:none instead
- [x] Fix deploy: add missing 023b migration to rename cc4r→cc2r, make run.php skip 'no such column' errors
- [x] Fix FK constraint violation on edit: pass null instead of 0 for absent orientation/ap/finality
- [x] Mots-clés: interactive tag search with HTMX suggestions, pill display, round bin-icon remove buttons
- [x] Mots-clés: lowercase enforcement, deduplication, absolute dropdown, keyboard arrows/enter/escape, blur hide, spacing + counter above input, CSV import lowercased, space-collapse normalization, minimum 3 keywords required
- [x] ErrorHandler: shared static helper for structured error_log + user-friendly messages with precise FK field extraction from SQLite errors. Applied to 12 action files + 6 public controllers + 2 form controllers + partage. Covers FK, UNIQUE, NOT NULL constraint types.
- [x] Fix: findOrCreateAuthor cannot clear email (empty string skips update, leaves old email)
- [x] Fix: "NON" stored as literal email string in authors table — cleaned existing DB rows, added OUI/NON→null guard in findOrCreateAuthor and CSV import
- [x] Fix: contact_interne field in edit form never saved — removed dead field from form and dead validation from create controller
- [x] Fix: formulaire.php unconditionally suppresses display_errors even in dev mode
- [x] Fix: access_type_id radio has no "none" option — added "— Non défini" radio for admin mode
- [x] Fix: radio button checked detection broken (int vs string strict comparison in fieldset-licence-explanation.php)
- [x] Rename author_email → contact_interne in v_theses_full view, controllers, forms, and templates
- [x] Rename author_show_contact → contact_public in v_theses_full view
- [x] Restore contact_interne backoffice field with proper variable name, wire to save (takes precedence over mail)
- [x] Fix: htmlspecialchars(null) crash in old() on admin/add.php and admin/edit.php (null values in form data)
- [x] Fix: jury-fieldset.php old() return type confusion (array vs string) for jury_lecteur:_interne:_externe keys

View File

@@ -0,0 +1,72 @@
<?php
require_once __DIR__ . '/../../../bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
http_response_code(403);
die("Accès refusé.");
}
require_once __DIR__ . '/../../../src/Database.php';
require_once __DIR__ . '/../../../src/AdminLogger.php';
require_once __DIR__ . '/../../../src/ErrorHandler.php';
try {
$db = new Database();
$logger = AdminLogger::make();
$action = $_POST['action'] ?? '';
switch ($action) {
case 'rename':
$id = filter_var($_POST['language_id'] ?? '', FILTER_VALIDATE_INT);
$newName = trim($_POST['new_name'] ?? '');
if (!$id || $newName === '') throw new Exception("Paramètres invalides.");
$db->renameLanguage($id, $newName);
break;
case 'merge':
$sourceId = filter_var($_POST['source_id'] ?? '', FILTER_VALIDATE_INT);
$targetId = filter_var($_POST['target_id'] ?? '', FILTER_VALIDATE_INT);
if (!$sourceId || !$targetId) throw new Exception("Paramètres invalides.");
$db->mergeLanguage($sourceId, $targetId);
break;
case 'merge_bulk':
$targetId = filter_var($_POST['target_id'] ?? '', FILTER_VALIDATE_INT);
$sourceIds = isset($_POST['selected_langs']) && is_array($_POST['selected_langs'])
? array_map('intval', $_POST['selected_langs'])
: [];
if (!$targetId || empty($sourceIds)) throw new Exception("Paramètres invalides.");
$sourceIds = array_values(array_diff($sourceIds, [$targetId]));
if (empty($sourceIds)) throw new Exception("Aucune source à fusionner.");
foreach ($sourceIds as $sid) {
$db->mergeLanguage($sid, $targetId);
}
break;
case 'delete':
$id = filter_var($_POST['language_id'] ?? '', FILTER_VALIDATE_INT);
if (!$id) throw new Exception("ID invalide.");
$db->deleteLanguage($id);
break;
default:
throw new Exception("Action inconnue.");
}
App::flash('success', "Opération effectuée.");
} catch (Exception $e) {
ErrorHandler::log('language', $e);
App::flash('error', ErrorHandler::userMessage($e));
}
$redirect = '/admin/contenus.php';
// Allow the caller to override the redirect
if (!empty($_POST['return']) && str_starts_with($_POST['return'], '/')) {
$redirect = $_POST['return'];
}
header('Location: ' . $redirect);
exit();

View File

@@ -36,6 +36,20 @@ try {
$logger->logTagAction('merge', ['source_id' => $sourceId, 'target_id' => $targetId]); $logger->logTagAction('merge', ['source_id' => $sourceId, 'target_id' => $targetId]);
break; break;
case 'merge_bulk':
$targetId = filter_var($_POST['target_id'] ?? '', FILTER_VALIDATE_INT);
$sourceIds = isset($_POST['selected_tags']) && is_array($_POST['selected_tags'])
? array_map('intval', $_POST['selected_tags'])
: [];
if (!$targetId || empty($sourceIds)) throw new Exception("Paramètres invalides.");
$sourceIds = array_values(array_diff($sourceIds, [$targetId]));
if (empty($sourceIds)) throw new Exception("Aucune source à fusionner.");
foreach ($sourceIds as $sid) {
$db->mergeTag($sid, $targetId);
$logger->logTagAction('merge', ['source_id' => $sid, 'target_id' => $targetId]);
}
break;
case 'delete': case 'delete':
$id = filter_var($_POST['tag_id'] ?? '', FILTER_VALIDATE_INT); $id = filter_var($_POST['tag_id'] ?? '', FILTER_VALIDATE_INT);
if (!$id) throw new Exception("ID invalide."); if (!$id) throw new Exception("ID invalide.");
@@ -53,5 +67,10 @@ try {
App::flash('error', ErrorHandler::userMessage($e)); App::flash('error', ErrorHandler::userMessage($e));
} }
header('Location: /admin/tags.php'); $redirect = '/admin/tags.php';
// Allow the caller to override the redirect
if (!empty($_POST['return']) && str_starts_with($_POST['return'], '/')) {
$redirect = $_POST['return'];
}
header('Location: ' . $redirect);
exit(); exit();

View File

@@ -0,0 +1,114 @@
<?php
/**
* contenus-langues-fragment.php
*
* HTMX fragment: returns the langues table for the contenus page,
* optionally filtered by a search query.
*/
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
require_once __DIR__ . '/../../src/Database.php';
$searchQuery = trim($_GET['q'] ?? '');
try {
$db = new Database();
$languages = ($searchQuery !== '') ? $db->searchLanguages($searchQuery) : $db->getAllLanguagesWithCount();
} catch (Exception $e) {
die('<div class="flash-error">Erreur : ' . htmlspecialchars($e->getMessage()) . '</div>');
}
?>
<div id="langues-bulk-actions" class="admin-bulk-actions" style="display:none">
<strong><span id="langues-selected-count">0</span> langue(s) sélectionnée(s)</strong>
<div class="admin-bulk-btns">
<button type="button" class="btn btn--sm btn--warning admin-btn-merge"
onclick="languesConfirmBulkMerge()"
title="Fusionner les langues sélectionnées">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M224,152V96a8,8,0,0,0-8-8H168V40a8,8,0,0,0-8-8H40a8,8,0,0,0-8,8v64h0v56a8,8,0,0,0,8,8H88v48a8,8,0,0,0,8,8H216a8,8,0,0,0,8-8V152Zm-68.69,56L48,100.69V59.31L196.69,208Zm-96-160h41.38L208,155.31v41.38ZM208,132.69,179.31,104H208Zm-56-56L123.31,48H152ZM48,123.31,76.69,152H48Zm56,56L132.69,208H104Z"></path></svg>
Fusionner
</button>
</div>
</div>
<form id="langues-bulk-form" method="post" action="actions/language.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="merge_bulk">
<input type="hidden" name="return" value="/admin/contenus.php">
<input type="hidden" name="target_id" id="langues-bulk-target" value="">
<div id="langues-bulk-checkboxes"></div>
</form>
<table>
<thead>
<tr>
<th scope="col"><input type="checkbox" onchange="languesToggleAll(this)"></th>
<th scope="col">Nom</th>
<th scope="col">TFE Associé</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($languages)): ?>
<tr><td colspan="4" class="admin-empty">Aucune langue trouvée.</td></tr>
<?php else: ?>
<?php foreach ($languages as $lang): ?>
<tr>
<td><input type="checkbox" name="selected_langs[]" value="<?= (int)$lang['id'] ?>" onchange="languesUpdateBulk()"></td>
<td><?= htmlspecialchars($lang['name']) ?></td>
<td class="admin-tags-count"><?= (int)$lang['thesis_count'] ?></td>
<td class="admin-actions-col">
<div class="admin-actions">
<form method="post" action="actions/language.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="rename">
<input type="hidden" name="return" value="/admin/contenus.php">
<input type="hidden" name="language_id" value="<?= (int)$lang['id'] ?>">
<input class="admin-input--inline" type="text" name="new_name"
value="<?= htmlspecialchars($lang['name']) ?>" required>
<button type="submit" class="admin-icon-btn admin-icon-btn--edit" title="Renommer">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M248,92.68a15.86,15.86,0,0,0-4.69-11.31L174.63,12.68a16,16,0,0,0-22.63,0L123.57,41.11l-58,21.77A16.06,16.06,0,0,0,55.35,75.23L32.11,214.68A8,8,0,0,0,40,224a8.4,8.4,0,0,0,1.32-.11l139.44-23.24a16,16,0,0,0,12.35-10.17l21.77-58L243.31,104A15.87,15.87,0,0,0,248,92.68Zm-69.87,92.19L63.32,204l47.37-47.37a28,28,0,1,0-11.32-11.32L52,192.7,71.13,77.86,126,57.29,198.7,130ZM112,132a12,12,0,1,1,12,12A12,12,0,0,1,112,132Zm96-15.32L139.31,48l24-24L232,92.68Z"></path></svg>
</button>
</form>
<form method="post" action="actions/language.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="merge">
<input type="hidden" name="return" value="/admin/contenus.php">
<input type="hidden" name="source_id" value="<?= (int)$lang['id'] ?>">
<select name="target_id" class="admin-select--inline" required>
<option value="">— Fusionner dans… —</option>
<?php foreach ($languages as $other): ?>
<?php if ($other['id'] !== $lang['id']): ?>
<option value="<?= (int)$other['id'] ?>"><?= htmlspecialchars($other['name']) ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<button type="button" class="admin-icon-btn admin-icon-btn--merge" title="Fusionner"
onclick="return languesConfirmMerge(this)">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"></path></svg>
</button>
</form>
<form method="post" action="actions/language.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="return" value="/admin/contenus.php">
<input type="hidden" name="language_id" value="<?= (int)$lang['id'] ?>">
<button type="button" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer"
onclick="languesConfirmDelete(this, <?= htmlspecialchars(json_encode($lang['name']), ENT_QUOTES) ?>)">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" 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-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"></path></svg>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>

View File

@@ -0,0 +1,109 @@
<?php
/**
* tags-fragment.php
*
* HTMX fragment: returns the tags table, optionally filtered by search query.
*/
require_once __DIR__ . '/../../bootstrap.php';
require_once __DIR__ . '/../../src/AdminAuth.php';
AdminAuth::requireLogin();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
require_once __DIR__ . '/../../src/Database.php';
$searchQuery = trim($_GET['q'] ?? '');
try {
$db = new Database();
$tags = ($searchQuery !== '') ? $db->searchTags($searchQuery) : $db->getAllTagsWithCount();
} catch (Exception $e) {
die('<div class="flash-error">Erreur : ' . htmlspecialchars($e->getMessage()) . '</div>');
}
?>
<div id="tags-bulk-actions" class="admin-bulk-actions" style="display:none">
<strong><span id="tags-selected-count">0</span> mot(s)-clé(s) sélectionné(s)</strong>
<div class="admin-bulk-btns">
<button type="button" class="btn btn--sm btn--warning admin-btn-merge"
onclick="tagsConfirmBulkMerge()"
title="Fusionner les mots-clés sélectionnés">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M224,152V96a8,8,0,0,0-8-8H168V40a8,8,0,0,0-8-8H40a8,8,0,0,0-8,8v64h0v56a8,8,0,0,0,8,8H88v48a8,8,0,0,0,8,8H216a8,8,0,0,0,8-8V152Zm-68.69,56L48,100.69V59.31L196.69,208Zm-96-160h41.38L208,155.31v41.38ZM208,132.69,179.31,104H208Zm-56-56L123.31,48H152ZM48,123.31,76.69,152H48Zm56,56L132.69,208H104Z"></path></svg>
Fusionner
</button>
</div>
</div>
<form id="tags-bulk-form" method="post" action="actions/tag.php">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="merge_bulk">
<input type="hidden" name="target_id" id="tags-bulk-target" value="">
<div id="tags-bulk-checkboxes"></div>
</form>
<table>
<thead>
<tr>
<th scope="col"><input type="checkbox" onchange="tagsToggleAll(this)"></th>
<th scope="col">Nom</th>
<th scope="col">TFE associés</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($tags)): ?>
<tr><td colspan="4" class="admin-empty">Aucun mot-clé trouvé.</td></tr>
<?php else: ?>
<?php foreach ($tags as $tag): ?>
<tr>
<td><input type="checkbox" name="selected_tags[]" value="<?= (int)$tag['id'] ?>" onchange="tagsUpdateBulk()"></td>
<td><?= htmlspecialchars($tag['name']) ?></td>
<td class="admin-tags-count"><?= (int)$tag['thesis_count'] ?></td>
<td class="admin-actions-col">
<div class="admin-actions">
<form method="post" action="actions/tag.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="rename">
<input type="hidden" name="tag_id" value="<?= (int)$tag['id'] ?>">
<input class="admin-input--inline" type="text" name="new_name"
value="<?= htmlspecialchars($tag['name']) ?>" required>
<button type="submit" class="admin-icon-btn admin-icon-btn--edit" title="Renommer">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M248,92.68a15.86,15.86,0,0,0-4.69-11.31L174.63,12.68a16,16,0,0,0-22.63,0L123.57,41.11l-58,21.77A16.06,16.06,0,0,0,55.35,75.23L32.11,214.68A8,8,0,0,0,40,224a8.4,8.4,0,0,0,1.32-.11l139.44-23.24a16,16,0,0,0,12.35-10.17l21.77-58L243.31,104A15.87,15.87,0,0,0,248,92.68Zm-69.87,92.19L63.32,204l47.37-47.37a28,28,0,1,0-11.32-11.32L52,192.7,71.13,77.86,126,57.29,198.7,130ZM112,132a12,12,0,1,1,12,12A12,12,0,0,1,112,132Zm96-15.32L139.31,48l24-24L232,92.68Z"></path></svg>
</button>
</form>
<form method="post" action="actions/tag.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="merge">
<input type="hidden" name="source_id" value="<?= (int)$tag['id'] ?>">
<select name="target_id" class="admin-select--inline" required>
<option value="">— Fusionner dans… —</option>
<?php foreach ($tags as $other): ?>
<?php if ($other['id'] !== $tag['id']): ?>
<option value="<?= (int)$other['id'] ?>"><?= htmlspecialchars($other['name']) ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<button type="button" class="admin-icon-btn admin-icon-btn--merge" title="Fusionner"
onclick="return tagsConfirmMerge(this)">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"></path></svg>
</button>
</form>
<form method="post" action="actions/tag.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="tag_id" value="<?= (int)$tag['id'] ?>">
<button type="button" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer"
onclick="tagsConfirmDelete(this, <?= htmlspecialchars(json_encode($tag['name']), ENT_QUOTES) ?>)">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" 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-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"></path></svg>
</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>

View File

@@ -7,17 +7,8 @@ if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} }
require_once __DIR__ . '/../../src/Database.php';
$pageTitle = "Gestion des mots-clés"; $pageTitle = "Gestion des mots-clés";
try {
$db = new Database();
$tags = $db->getAllTagsWithCount();
} catch (Exception $e) {
die("Erreur : " . htmlspecialchars($e->getMessage()));
}
$isAdmin = true; $bodyClass = 'admin-body'; $isAdmin = true; $bodyClass = 'admin-body';
require_once APP_ROOT . '/templates/head.php'; require_once APP_ROOT . '/templates/head.php';
include APP_ROOT . '/templates/header.php'; include APP_ROOT . '/templates/header.php';

View File

@@ -615,16 +615,14 @@ th.admin-ap-col {
margin-bottom: var(--space-xl); margin-bottom: var(--space-xl);
} }
/* Fieldsets inside flat sections: no card border */ /* Fieldsets inside flat settings sections: no card border */
.admin-body main > section[aria-labelledby^="settings-"] fieldset, .admin-body main > section[aria-labelledby^="settings-"] fieldset {
.admin-body main > section[aria-labelledby^="form-settings-"] fieldset {
border: none; border: none;
border-radius: 0; border-radius: 0;
padding: var(--space-m) 0; padding: var(--space-m) 0;
} }
.admin-body main > section[aria-labelledby^="settings-"] fieldset legend, .admin-body main > section[aria-labelledby^="settings-"] fieldset legend {
.admin-body main > section[aria-labelledby^="form-settings-"] fieldset legend {
padding: 0; padding: 0;
font-weight: 600; font-weight: 600;
letter-spacing: 0.04em; letter-spacing: 0.04em;
@@ -633,8 +631,7 @@ th.admin-ap-col {
} }
.admin-body main > section[aria-labelledby^="settings-"] > h2, .admin-body main > section[aria-labelledby^="settings-"] > h2,
.admin-body main > section[aria-labelledby^="static-pages-"] > h2, .admin-body main > section[aria-labelledby^="static-pages-"] > h2 {
.admin-body main > section[aria-labelledby^="form-settings-"] > h2 {
font-weight: 600; font-weight: 600;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
@@ -1992,3 +1989,56 @@ th.admin-ap-col {
50.01% { transform: scaleX(1); transform-origin: right; } 50.01% { transform: scaleX(1); transform-origin: right; }
100% { transform: scaleX(0); transform-origin: right; } 100% { transform: scaleX(0); transform-origin: right; }
} }
/* ── Sidebar TOC ───────────────────────────────────────────────────────────── */
.admin-with-toc {
display: flex;
gap: var(--space-m);
align-items: flex-start;
max-width: var(--content-max-width, 1200px);
margin: 0 auto;
padding: 0 var(--space-s);
}
.admin-with-toc > main {
flex: 1;
min-width: 0;
}
.admin-toc {
position: sticky;
top: var(--space-m);
width: 160px;
flex-shrink: 0;
padding-top: var(--space-s);
}
.admin-toc-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-toc-list a {
display: block;
padding: var(--space-3xs) var(--space-2xs);
font-size: var(--step--2);
color: var(--text-secondary);
text-decoration: none;
border-left: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.admin-toc-list a:hover {
color: var(--text-primary);
}
.admin-toc-list a.admin-toc-active {
color: var(--text-primary);
font-weight: 600;
border-left-color: var(--accent, var(--color-primary));
}

View File

@@ -1252,6 +1252,107 @@ class Database
$this->pdo->prepare('DELETE FROM tags WHERE id = ?')->execute([$id]); $this->pdo->prepare('DELETE FROM tags WHERE id = ?')->execute([$id]);
} }
// ========================================================================
// LANGUAGE MANAGEMENT (admin)
// ========================================================================
/**
* Search languages by name prefix. Returns up to 10 matching languages.
* If $query is empty, returns the most-used languages (up to 10).
*/
public function searchLanguages(string $query = ''): array
{
$query = trim($query);
if ($query === '') {
$stmt = $this->pdo->query('
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
GROUP BY l.id
ORDER BY thesis_count DESC, l.name COLLATE NOCASE
LIMIT 10
');
} else {
$stmt = $this->pdo->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 LOWER(?)
GROUP BY l.id
ORDER BY LOWER(l.name) = LOWER(?) DESC, thesis_count DESC, l.name COLLATE NOCASE
LIMIT 10
');
$stmt->execute([$query . '%', $query]);
}
return $stmt->fetchAll();
}
/**
* Return all languages with a count of associated theses.
*/
public function getAllLanguagesWithCount(): array
{
$stmt = $this->pdo->query('
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
GROUP BY l.id
ORDER BY l.name COLLATE NOCASE
');
return $stmt->fetchAll();
}
/**
* Rename a language. Throws if the new name already exists.
*/
public function renameLanguage(int $id, string $newName): void
{
$newName = trim($newName);
if ($newName === '') {
throw new Exception('Le nom de la langue ne peut pas être vide.');
}
$stmt = $this->pdo->prepare('SELECT id FROM languages WHERE LOWER(name) = LOWER(?) AND id != ?');
$stmt->execute([$newName, $id]);
if ($stmt->fetch()) {
throw new Exception('Une langue avec ce nom existe déjà.');
}
$this->pdo->prepare('UPDATE languages SET name = ? WHERE id = ?')->execute([$newName, $id]);
}
/**
* Merge sourceId into targetId: reassign all thesis_languages rows, then delete source.
*/
public function mergeLanguage(int $sourceId, int $targetId): void
{
if ($sourceId === $targetId) {
throw new Exception('Source et destination identiques.');
}
$this->pdo->beginTransaction();
try {
$this->pdo->prepare('
INSERT OR IGNORE INTO thesis_languages (language_id, thesis_id)
SELECT ?, thesis_id FROM thesis_languages WHERE language_id = ?
')->execute([$targetId, $sourceId]);
$this->pdo->prepare('DELETE FROM thesis_languages WHERE language_id = ?')->execute([$sourceId]);
$this->pdo->prepare('DELETE FROM languages WHERE id = ?')->execute([$sourceId]);
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
/**
* Delete a language and all its thesis_languages rows.
*/
public function deleteLanguage(int $id): void
{
$this->pdo->prepare('DELETE FROM languages WHERE id = ?')->execute([$id]);
}
/** /**
* Get orientation ID by name * Get orientation ID by name
*/ */

View File

@@ -1,3 +1,5 @@
<div class="admin-with-toc">
<?php include APP_ROOT . '/templates/admin/partials/admin-toc.php'; ?>
<main id="main-content"> <main id="main-content">
<h1>Accès</h1> <h1>Accès</h1>
@@ -126,6 +128,19 @@
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision) +%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: sntroxlt 6a5b93f3 "Move Formulaire settings to contenus, remove delete-all TFE" (rebased revision) +\\\\\\\ to: sntroxlt 6a5b93f3 "Move Formulaire settings to contenus, remove delete-all TFE" (rebased revision)
++ $linkName = $link['name'] ?? ''; ++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: sntroxlt 6a5b93f3 "Move Formulaire settings to contenus, remove delete-all TFE" (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: tyotlpxt ceaca548 "Add Mots-clés and Langues management to contenus page" (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: tyotlpxt f7b0f560 "Add Mots-clés and Langues management to contenus page" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; ++ $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"> <tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer">
@@ -389,6 +404,7 @@
<?php endif; ?> <?php endif; ?>
</section> </section>
</main> </main>
</div>
<!-- ═══════════════════════ CREATE DIALOG ═══════════════════════ --> <!-- ═══════════════════════ CREATE DIALOG ═══════════════════════ -->
<dialog id="create-dialog" class="admin-dialog" aria-labelledby="create-dialog-title"> <dialog id="create-dialog" class="admin-dialog" aria-labelledby="create-dialog-title">

View File

@@ -1,3 +1,5 @@
<div class="admin-with-toc">
<?php include APP_ROOT . '/templates/admin/partials/admin-toc.php'; ?>
<main id="main-content"> <main id="main-content">
<h1>Contenus</h1> <h1>Contenus</h1>
@@ -44,6 +46,35 @@
</table> </table>
</section> </section>
<!-- ═══════════════════════════════════════════════════════════════════
DONNÉES SECONDAIRES
═══════════════════════════════════════════════════════════════════ -->
<section aria-labelledby="donnees-secondaires-title">
<h2 id="donnees-secondaires-title">Données Secondaires</h2>
<!-- ── Mots-clés ── -->
<p><a href="/admin/tags.php" class="btn btn--sm btn--primary">Gérer les mots-clés</a></p>
<!-- ── Langues ── -->
<fieldset>
<legend>Langues</legend>
<form id="langues-search-form"
hx-get="/admin/contenus-langues-fragment.php"
hx-target="#langues-table-wrap"
hx-swap="innerHTML"
hx-trigger="input changed delay:200ms from:input[name=q], keyup[key=='Enter'] from:input[name=q]"
hx-push-url="false"
style="margin-bottom:var(--space-xs)">
<input type="text" name="q" placeholder="Rechercher une langue…" style="max-width:300px">
</form>
<div id="langues-table-wrap" hx-get="/admin/contenus-langues-fragment.php" hx-trigger="load" hx-swap="innerHTML">
<!-- populated by HTMX -->
</div>
</fieldset>
</section>
<!-- ═══════════════════════════════════════════════════════════════════ <!-- ═══════════════════════════════════════════════════════════════════
PARAMÈTRES DU FORMULAIRE PARAMÈTRES DU FORMULAIRE
═══════════════════════════════════════════════════════════════════ --> ═══════════════════════════════════════════════════════════════════ -->
@@ -163,44 +194,22 @@
<?php <?php
$blocks = $formHelpBlocks; $blocks = $formHelpBlocks;
// ── Student form structure — each help block above its fieldset ───────────
// Pairs: [help_key, fieldset_name, fieldset_inputs]
$pairs = [ $pairs = [
// Top of form
['partage_intro', null, null], ['partage_intro', null, null],
// Informations du TFE
['fieldset_tfe_info', 'Informations du TFE', ['fieldset_tfe_info', 'Informations du TFE',
['Titre', 'Sous-titre', 'Auteur·ice(s)', 'Contact visible', 'Synopsis']], ['Titre', 'Sous-titre', 'Auteur·ice(s)', 'Contact visible', 'Synopsis']],
// Langue(s)
['fieldset_languages', 'Langue(s)', ['fieldset_languages', 'Langue(s)',
['Langues du TFE (cases à cocher)', 'Autre(s) langue(s)']], ['Langues du TFE (cases à cocher)', 'Autre(s) langue(s)']],
// Mots-clés
['fieldset_keywords', 'Mots-clés', ['fieldset_keywords', 'Mots-clés',
['Mots-clés (max 10), séparés par des virgules']], ['Mots-clés (max 10), séparés par des virgules']],
// Cadre académique
['fieldset_academic', 'Cadre académique', ['fieldset_academic', 'Cadre académique',
['Année', 'Orientation', 'AP', 'Finalité']], ['Année', 'Orientation', 'AP', 'Finalité']],
// Composition du jury
['fieldset_jury', 'Composition du jury', ['fieldset_jury', 'Composition du jury',
['Président·e', 'Promoteur·ice(s)', 'Lecteur·ices']], ['Président·e', 'Promoteur·ice(s)', 'Lecteur·ices']],
// Format(s) + Fichiers
['fieldset_files', 'Format(s) + Fichiers', ['fieldset_files', 'Format(s) + Fichiers',
['Formats (PDF, vidéo, audio, site web…)', 'Couverture', 'Note d\'intention', 'Fichier principal', 'Annexes']], ['Formats (PDF, vidéo, audio, site web…)', 'Couverture', 'Note d\'intention', 'Fichier principal', 'Annexes']],
// Métadonnées complémentaires (supprimé)
// Ces champs sont redondants avec les fichiers attachés
// Degrés d'ouverture et licences
['fieldset_access', 'Degrés d\'ouverture et licences', ['fieldset_access', 'Degrés d\'ouverture et licences',
['Généralités', 'Degré (libre/interne/interdit)', 'Licence', 'CC2r']], ['Généralités', 'Degré (libre/interne/interdit)', 'Licence', 'CC2r']],
// E-mail de confirmation
['fieldset_email', 'E-mail de confirmation', ['fieldset_email', 'E-mail de confirmation',
['Adresse e-mail']], ['Adresse e-mail']],
]; ];
@@ -208,7 +217,6 @@
<div class="fhb-structure"> <div class="fhb-structure">
<?php foreach ($pairs as [$helpKey, $fieldsetName, $inputs]): <?php foreach ($pairs as [$helpKey, $fieldsetName, $inputs]):
// Help block
$b = $blocks[$helpKey] ?? ['content' => '', 'name' => '', 'enabled' => 0]; $b = $blocks[$helpKey] ?? ['content' => '', 'name' => '', 'enabled' => 0];
$title = $b['name'] ?: ($fieldsetName ?? $helpKey); $title = $b['name'] ?: ($fieldsetName ?? $helpKey);
?> ?>
@@ -239,6 +247,131 @@
</section> </section>
</main> </main>
</div>
<!-- ══════════════════════════════════════════════════════════════
CONFIRM DIALOGS FOR LANGUES
══════════════════════════════════════════════════════════════ -->
<dialog id="langues-merge-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="langues-merge-title">
<div class="admin-dialog__header">
<h2 id="langues-merge-title">Fusionner la langue</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="this.closest('dialog').close()">&#x2715;</button>
</div>
<div class="admin-dialog__alert">
<p>Fusionner dans « <strong id="langues-merge-target-name"></strong> » ? La langue source sera supprimée.</p>
</div>
<div class="admin-dialog__footer">
<button type="button" class="btn btn--warning" onclick="this.closest('dialog').close(); languesSubmitPending()">Fusionner</button>
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div>
</dialog>
<dialog id="langues-delete-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="langues-delete-title">
<div class="admin-dialog__header">
<h2 id="langues-delete-title">Supprimer la langue</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="this.closest('dialog').close()">&#x2715;</button>
</div>
<div class="admin-dialog__alert">
<p>Supprimer « <strong id="langues-delete-name"></strong> » ? Cette action est irréversible.</p>
</div>
<div class="admin-dialog__footer">
<button type="button" class="btn btn--danger" onclick="this.closest('dialog').close(); languesSubmitPending()">Supprimer</button>
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div>
</dialog>
<dialog id="langues-bulk-merge-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="langues-bulk-merge-title">
<div class="admin-dialog__header">
<h2 id="langues-bulk-merge-title">Fusionner des langues</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="this.closest('dialog').close()">&#x2715;</button>
</div>
<div class="admin-dialog__alert">
<p>Fusionner <strong><span id="langues-bulk-merge-count"></span> langue(s)</strong> sélectionnée(s) dans :</p>
<select id="langues-bulk-merge-target-select" class="admin-select--inline" style="margin-top:var(--space-xs); width:100%" required>
<option value="">— Choisir la destination —</option>
</select>
</div>
<div class="admin-dialog__footer">
<button type="button" class="btn btn--warning" onclick="languesExecBulkMerge()">Fusionner</button>
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div>
</dialog>
<script>
let _languesPendingForm = null;
function languesConfirmMerge(btn) {
const form = btn.closest('form');
const select = form.querySelector('select[name="target_id"]');
if (!select.value) return true;
_languesPendingForm = form;
document.getElementById('langues-merge-target-name').textContent = select.options[select.selectedIndex]?.text ?? '';
document.getElementById('langues-merge-dialog').showModal();
return false;
}
function languesConfirmDelete(btn, name) {
_languesPendingForm = btn.closest('form');
document.getElementById('langues-delete-name').textContent = name;
document.getElementById('langues-delete-dialog').showModal();
}
function languesSubmitPending() {
if (_languesPendingForm) _languesPendingForm.submit();
}
function languesToggleAll(src) {
document.querySelectorAll('input[name="selected_langs[]"]').forEach(cb => cb.checked = src.checked);
languesUpdateBulk();
}
function languesUpdateBulk() {
const n = document.querySelectorAll('input[name="selected_langs[]"]:checked').length;
document.getElementById('langues-selected-count').textContent = n;
document.getElementById('langues-bulk-actions').style.display = n > 1 ? 'flex' : 'none';
}
function languesConfirmBulkMerge() {
const checked = document.querySelectorAll('input[name="selected_langs[]"]:checked');
if (checked.length < 2) return;
document.getElementById('langues-bulk-merge-count').textContent = checked.length;
const sel = document.getElementById('langues-bulk-merge-target-select');
sel.innerHTML = '<option value="">— Choisir la destination —</option>';
checked.forEach(cb => {
const tr = cb.closest('tr');
sel.innerHTML += '<option value="' + cb.value + '">' + tr.querySelector('td:nth-child(2)').textContent.trim() + '</option>';
});
document.getElementById('langues-bulk-merge-dialog').showModal();
}
function languesExecBulkMerge() {
const targetId = document.getElementById('langues-bulk-merge-target-select').value;
if (!targetId) return;
document.getElementById('langues-bulk-target').value = targetId;
const container = document.getElementById('langues-bulk-checkboxes');
container.innerHTML = '';
document.querySelectorAll('input[name="selected_langs[]"]:checked').forEach(cb => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'selected_langs[]';
inp.value = cb.value;
container.appendChild(inp);
});
document.getElementById('langues-bulk-merge-dialog').close();
document.getElementById('langues-bulk-form').submit();
}
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.target.id === 'langues-table-wrap') {
document.querySelectorAll('input[name="selected_langs[]"]').forEach(cb => cb.addEventListener('change', languesUpdateBulk));
languesUpdateBulk();
}
});
</script>
<script> <script>
(function () { (function () {

View File

@@ -1,3 +1,5 @@
<div class="admin-with-toc">
<?php include APP_ROOT . '/templates/admin/partials/admin-toc.php'; ?>
<main id="main-content"> <main id="main-content">
<h1>Paramètres</h1> <h1>Paramètres</h1>
@@ -458,6 +460,7 @@
</div> </div>
</section> </section>
</main> </main>
</div>
<script> <script>
function copyLogContent(btn) { function copyLogContent(btn) {

View File

@@ -0,0 +1,78 @@
<?php
/**
* admin-toc.php — sidebar table-of-contents for long admin pages.
*
* Scans <section aria-labelledby="..."> elements in #main-content and builds a
* slim vertical nav. Uses IntersectionObserver to highlight the active section.
*
* Usage: include APP_ROOT . '/templates/admin/partials/admin-toc.php';
*/
?>
<nav id="admin-toc" class="admin-toc" aria-label="Sur cette page">
<ul class="admin-toc-list" id="admin-toc-list">
<!-- populated by JS -->
</ul>
</nav>
<script>
(function() {
var main = document.getElementById('main-content');
if (!main) return;
var tocList = document.getElementById('admin-toc-list');
if (!tocList) return;
// Find all labelled sections
var sections = main.querySelectorAll('section[aria-labelledby]');
if (sections.length < 2) {
document.getElementById('admin-toc').style.display = 'none';
return;
}
var items = [];
sections.forEach(function(sec) {
var headingId = sec.getAttribute('aria-labelledby');
var heading = document.getElementById(headingId);
if (!heading) return;
var li = document.createElement('li');
var a = document.createElement('a');
a.href = '#' + sec.id;
a.textContent = heading.textContent;
a.setAttribute('data-toc-target', sec.id);
li.appendChild(a);
tocList.appendChild(li);
// Ensure section has an id for anchoring
if (!sec.id) {
sec.id = headingId;
}
items.push({ section: sec, link: a });
});
// IntersectionObserver: highlight the link whose section is most visible
var observer = new IntersectionObserver(function(entries) {
var best = null;
var bestRatio = 0;
entries.forEach(function(e) {
if (e.intersectionRatio > bestRatio) {
bestRatio = e.intersectionRatio;
best = e.target;
}
});
if (best) {
items.forEach(function(item) {
var isActive = item.section === best;
item.link.classList.toggle('admin-toc-active', isActive);
});
}
}, {
rootMargin: '-10% 0px -70% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1]
});
items.forEach(function(item) { observer.observe(item.section); });
})();
</script>

View File

@@ -1,18 +1,17 @@
<script> <script>
let _pendingTagForm = null; let _pendingTagForm = null;
function confirmMergeTag(btn) { function tagsConfirmMerge(btn) {
const form = btn.closest('form'); const form = btn.closest('form');
const select = form.querySelector('select[name="target_id"]'); const select = form.querySelector('select[name="target_id"]');
const targetName = select.options[select.selectedIndex]?.text ?? ''; if (!select.value) return true;
if (!select.value) { return true; } // let HTML validation handle empty
_pendingTagForm = form; _pendingTagForm = form;
document.getElementById('merge-target-name').textContent = targetName; document.getElementById('merge-target-name').textContent = select.options[select.selectedIndex]?.text ?? '';
document.getElementById('merge-tag-dialog').showModal(); document.getElementById('merge-tag-dialog').showModal();
return false; return false;
} }
function confirmDeleteTag(btn, name) { function tagsConfirmDelete(btn, name) {
_pendingTagForm = btn.closest('form'); _pendingTagForm = btn.closest('form');
document.getElementById('delete-tag-name').textContent = name; document.getElementById('delete-tag-name').textContent = name;
document.getElementById('delete-tag-dialog').showModal(); document.getElementById('delete-tag-dialog').showModal();
@@ -21,82 +20,80 @@ function confirmDeleteTag(btn, name) {
function _submitPendingTagForm() { function _submitPendingTagForm() {
if (_pendingTagForm) _pendingTagForm.submit(); if (_pendingTagForm) _pendingTagForm.submit();
} }
function tagsToggleAll(src) {
document.querySelectorAll('input[name="selected_tags[]"]').forEach(cb => cb.checked = src.checked);
tagsUpdateBulk();
}
function tagsUpdateBulk() {
const n = document.querySelectorAll('input[name="selected_tags[]"]:checked').length;
document.getElementById('tags-selected-count').textContent = n;
document.getElementById('tags-bulk-actions').style.display = n > 1 ? 'flex' : 'none';
}
function tagsConfirmBulkMerge() {
const checked = document.querySelectorAll('input[name="selected_tags[]"]:checked');
if (checked.length < 2) return;
document.getElementById('bulk-merge-count').textContent = checked.length;
const sel = document.getElementById('bulk-merge-target-select');
sel.innerHTML = '<option value="">— Choisir la destination —</option>';
checked.forEach(cb => {
const tr = cb.closest('tr');
sel.innerHTML += '<option value="' + cb.value + '">' + tr.querySelector('td:nth-child(2)').textContent.trim() + '</option>';
});
document.getElementById('bulk-merge-dialog').showModal();
}
function tagsExecBulkMerge() {
const targetId = document.getElementById('bulk-merge-target-select').value;
if (!targetId) return;
document.getElementById('tags-bulk-target').value = targetId;
const container = document.getElementById('tags-bulk-checkboxes');
container.innerHTML = '';
document.querySelectorAll('input[name="selected_tags[]"]:checked').forEach(cb => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'selected_tags[]';
inp.value = cb.value;
container.appendChild(inp);
});
document.getElementById('bulk-merge-dialog').close();
document.getElementById('tags-bulk-form').submit();
}
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.target.id === 'tags-table-wrap') {
document.querySelectorAll('input[name="selected_tags[]"]').forEach(cb => cb.addEventListener('change', tagsUpdateBulk));
tagsUpdateBulk();
}
});
</script> </script>
<main id="main-content" class="admin-main--list"> <main id="main-content" class="admin-main--list">
<div class="admin-list-toolbar admin-list-toolbar--list" style="margin-bottom:var(--space-s)"> <div class="admin-list-toolbar admin-list-toolbar--list" style="margin-bottom:var(--space-s)">
<div class="admin-toolbar-top"> <div class="admin-toolbar-top">
<div class="admin-toolbar-title-row"> <div class="admin-toolbar-title-row">
<h1><a href="/admin/" class="admin-back-btn" title="Retour à la liste"><svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm48-88a8,8,0,0,1-8,8H107.31l18.35,18.34a8,8,0,0,1-11.32,11.32l-32-32a8,8,0,0,1,0-11.32l32-32a8,8,0,0,1,11.32,11.32L107.31,120H168A8,8,0,0,1,176,128Z"></path></svg></a> Mots-clés (<?= count($tags) ?>)</h1> <h1><a href="/admin/" class="admin-back-btn" title="Retour à la liste"><svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm48-88a8,8,0,0,1-8,8H107.31l18.35,18.34a8,8,0,0,1-11.32,11.32l-32-32a8,8,0,0,1,0-11.32l32-32a8,8,0,0,1,11.32,11.32L107.31,120H168A8,8,0,0,1,176,128Z"></path></svg></a> Mots-clés</h1>
</div>
</div> </div>
</div> </div>
<div id="admin-table-wrap"> <form id="tags-search-form"
<table> hx-get="/admin/tags-fragment.php"
<thead> hx-target="#tags-table-wrap"
<tr> hx-swap="innerHTML"
<th scope="col">Nom</th> hx-trigger="input changed delay:200ms from:input[name=q], keyup[key=='Enter'] from:input[name=q]"
<th scope="col">TFE associés</th> hx-push-url="false"
<th scope="col">Actions</th> style="margin-bottom:var(--space-xs)">
</tr> <input type="text" name="q" placeholder="Rechercher un mot-clé…" style="max-width:300px">
</thead>
<tbody>
<?php foreach ($tags as $tag): ?>
<tr>
<td><?= htmlspecialchars($tag['name']) ?></td>
<td class="admin-tags-count"><?= (int)$tag['thesis_count'] ?></td>
<td>
<!-- Rename -->
<form method="post" action="actions/tag.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="rename">
<input type="hidden" name="tag_id" value="<?= (int)$tag['id'] ?>">
<input class="admin-input--inline" type="text" name="new_name"
value="<?= htmlspecialchars($tag['name']) ?>" required>
<button type="submit" class="admin-icon-btn admin-icon-btn--edit" title="Renommer">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M248,92.68a15.86,15.86,0,0,0-4.69-11.31L174.63,12.68a16,16,0,0,0-22.63,0L123.57,41.11l-58,21.77A16.06,16.06,0,0,0,55.35,75.23L32.11,214.68A8,8,0,0,0,40,224a8.4,8.4,0,0,0,1.32-.11l139.44-23.24a16,16,0,0,0,12.35-10.17l21.77-58L243.31,104A15.87,15.87,0,0,0,248,92.68Zm-69.87,92.19L63.32,204l47.37-47.37a28,28,0,1,0-11.32-11.32L52,192.7,71.13,77.86,126,57.29,198.7,130ZM112,132a12,12,0,1,1,12,12A12,12,0,0,1,112,132Zm96-15.32L139.31,48l24-24L232,92.68Z"></path></svg>
</button>
</form> </form>
</div>
<!-- Merge into another tag --> <div id="tags-table-wrap" hx-get="/admin/tags-fragment.php" hx-trigger="load" hx-swap="innerHTML">
<form method="post" action="actions/tag.php" class="admin-inline-form"> <!-- populated by HTMX -->
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="merge">
<input type="hidden" name="source_id" value="<?= (int)$tag['id'] ?>">
<select name="target_id" class="admin-select--inline" required>
<option value="">— Fusionner dans… —</option>
<?php foreach ($tags as $other): ?>
<?php if ($other['id'] !== $tag['id']): ?>
<option value="<?= (int)$other['id'] ?>"><?= htmlspecialchars($other['name']) ?></option>
<?php endif; ?>
<?php endforeach; ?>
</select>
<button type="button" class="admin-icon-btn admin-icon-btn--merge" title="Fusionner"
onclick="return confirmMergeTag(this)">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256"><path d="M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z"></path></svg>
</button>
</form>
<!-- Delete -->
<form method="post" action="actions/tag.php" class="admin-inline-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="tag_id" value="<?= (int)$tag['id'] ?>">
<button type="button" class="admin-icon-btn admin-icon-btn--delete" title="Supprimer"
onclick="confirmDeleteTag(this, <?= htmlspecialchars(json_encode($tag['name']), ENT_QUOTES) ?>)">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" 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-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"></path></svg>
</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div> </div>
</main> </main>
<!-- Merge tag confirm -->
<dialog id="merge-tag-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="merge-tag-title"> <dialog id="merge-tag-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="merge-tag-title">
<div class="admin-dialog__header"> <div class="admin-dialog__header">
<h2 id="merge-tag-title">Fusionner le tag</h2> <h2 id="merge-tag-title">Fusionner le tag</h2>
@@ -112,7 +109,6 @@ function _submitPendingTagForm() {
</div> </div>
</dialog> </dialog>
<!-- Delete tag confirm -->
<dialog id="delete-tag-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="delete-tag-title"> <dialog id="delete-tag-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="delete-tag-title">
<div class="admin-dialog__header"> <div class="admin-dialog__header">
<h2 id="delete-tag-title">Supprimer le tag</h2> <h2 id="delete-tag-title">Supprimer le tag</h2>
@@ -127,3 +123,21 @@ function _submitPendingTagForm() {
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button> <button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div> </div>
</dialog> </dialog>
<dialog id="bulk-merge-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="bulk-merge-title">
<div class="admin-dialog__header">
<h2 id="bulk-merge-title">Fusionner des tags</h2>
<button type="button" class="admin-dialog__close" aria-label="Fermer"
onclick="this.closest('dialog').close()">&#x2715;</button>
</div>
<div class="admin-dialog__alert">
<p>Fusionner <strong><span id="bulk-merge-count"></span> tag(s)</strong> sélectionné(s) dans :</p>
<select id="bulk-merge-target-select" class="admin-select--inline" style="margin-top:var(--space-xs); width:100%" required>
<option value=""> Choisir la destination </option>
</select>
</div>
<div class="admin-dialog__footer">
<button type="button" class="btn btn--warning" onclick="tagsExecBulkMerge()">Fusionner</button>
<button type="button" class="btn btn--secondary" onclick="this.closest('dialog').close()">Annuler</button>
</div>
</dialog>