Add delete/batch-delete and sortable columns to admin list

- Database: add deleteThesis() and bulkDeleteTheses() methods with file cleanup
- Database: add SORT_MAP + buildOrderBy() for safe column sorting
- Database: getThesesList() now respects sort/dir filter params
- New action: actions/delete.php (single + batch delete with CSRF)
- Admin index: delete button per row with confirmation dialog
- Admin index: batch 'Supprimer' button in bulk actions bar
- Admin index: sortable column headers (ID, Titre, Année, Orientation, AP, Statut)
- Admin index: sort state preserved in pagination links
- CSS: admin-btn-delete (red muted), admin-sort-link styles
This commit is contained in:
Pontoporeia
2026-04-15 12:58:03 +02:00
parent 1b104df51e
commit fd4fb5ce4a
5 changed files with 225 additions and 9 deletions

View File

@@ -23,3 +23,7 @@
- [x] tfe.php: contact shown from author_email+show_contact; baiu_link relabeled as "Lien"
- [x] actions/settings.php: handler for formulaire settings form
- [x] CSS: admin-toggle pill switches + admin-settings-toggles layout + admin-form-group
- [x] Fix undefined $from variable in admin/index.php (brace-interpolate around en-dash)
- [x] Add delete single entry to admin table (delete action + handler)
- [x] Add batch delete to bulk actions bar
- [x] Add sortable columns to admin table (click column headers to sort)

View File

@@ -0,0 +1,55 @@
<?php
require_once __DIR__ . '/../../../config/bootstrap.php';
require_once __DIR__ . '/../../../src/AdminAuth.php';
AdminAuth::requireLogin();
require_once __DIR__ . '/../../../src/Database.php';
// CSRF validation
if (!isset($_POST['csrf_token'], $_SESSION['csrf_token'])
|| !hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'])) {
App::flash('error', 'Erreur de sécurité : token invalide.');
header('Location: ../index.php');
exit;
}
$isBulk = !empty($_POST['bulk']);
try {
$db = new Database();
if ($isBulk) {
$ids = array_filter(array_map('intval', $_POST['selected_theses'] ?? []), fn($id) => $id > 0);
if (empty($ids)) {
App::flash('error', 'Aucun TFE sélectionné.');
header('Location: ../index.php');
exit;
}
$db->bulkDeleteTheses($ids);
$count = count($ids);
App::flash('success', "$count TFE(s) supprimé(s) avec succès.");
} else {
$thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT);
if (!$thesisId || $thesisId <= 0) {
App::flash('error', 'ID invalide.');
header('Location: ../index.php');
exit;
}
$db->deleteThesis($thesisId);
App::flash('success', 'TFE supprimé avec succès.');
}
} catch (Exception $e) {
error_log('delete.php error: ' . $e->getMessage());
App::flash('error', 'Erreur lors de la suppression : ' . $e->getMessage());
}
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
header('Location: ../index.php');
exit;

View File

@@ -222,11 +222,16 @@ try {
$orientationFilter = isset($_GET['orientation']) ? intval($_GET['orientation']) : null;
$apFilter = isset($_GET['ap']) ? intval($_GET['ap']) : null;
$sortCol = isset($_GET['sort']) ? trim($_GET['sort']) : 'submitted_at';
$sortDir = isset($_GET['dir']) ? trim($_GET['dir']) : 'desc';
$filters = [];
if ($searchQuery) $filters['search'] = $searchQuery;
if ($yearFilter) $filters['year'] = $yearFilter;
if ($orientationFilter) $filters['orientation'] = $orientationFilter;
if ($apFilter) $filters['ap'] = $apFilter;
$filters['sort'] = $sortCol;
$filters['dir'] = $sortDir;
$perPage = 25;
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
@@ -262,9 +267,18 @@ function updateBulk() {
function bulkAction(action) {
const checked = document.querySelectorAll('input[name="selected_theses[]"]:checked');
if (!checked.length) { alert('Sélectionnez au moins un TFE.'); return; }
const word = action === 'publish' ? 'publier' : 'dépublier';
let word, endpoint;
if (action === 'publish') { word = 'publier'; endpoint = 'actions/publish.php'; }
else if (action === 'unpublish') { word = 'dépublier'; endpoint = 'actions/publish.php'; }
else if (action === 'delete') { word = 'supprimer'; endpoint = 'actions/delete.php'; }
else return;
if (action === 'delete') {
if (!confirm(`Supprimer définitivement ${checked.length} TFE(s) ? Cette action est irréversible.`)) return;
} else {
if (!confirm(`${word.charAt(0).toUpperCase()+word.slice(1)} ${checked.length} TFE(s) ?`)) return;
}
document.getElementById('bulk-action-input').value = action;
document.getElementById('bulk-form').action = endpoint;
const container = document.getElementById('bulk-checkboxes');
container.innerHTML = '';
checked.forEach(cb => {
@@ -274,6 +288,11 @@ function bulkAction(action) {
});
document.getElementById('bulk-form').submit();
}
function deleteThesis(id, title) {
if (!confirm(`Supprimer « ${title} » ?\nCette action est irréversible.`)) return;
const form = document.getElementById('delete-form-' + id);
if (form) form.submit();
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('input[name="selected_theses[]"]').forEach(cb => cb.addEventListener('change', updateBulk));
});
@@ -346,6 +365,7 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="admin-bulk-btns">
<button type="button" class="admin-btn-sm admin-btn-publish" onclick="bulkAction('publish')">Publier</button>
<button type="button" class="admin-btn-sm admin-btn-unpublish" onclick="bulkAction('unpublish')">Dépublier</button>
<button type="button" class="admin-btn-sm admin-btn-delete" onclick="bulkAction('delete')">Supprimer</button>
</div>
</div>
@@ -371,17 +391,37 @@ document.addEventListener('DOMContentLoaded', () => {
}
?>
</p>
<?php
$sortParams = array_filter([
'search' => $searchQuery,
'year' => $yearFilter ?: '',
'orientation' => $orientationFilter ?: '',
'ap' => $apFilter ?: '',
]);
$sortLink = function(string $col) use ($sortCol, $sortDir, $sortParams): string {
$params = $sortParams;
$params['sort'] = $col;
$params['dir'] = ($sortCol === $col && $sortDir === 'desc') ? 'asc' : 'desc';
return '/admin/?' . http_build_query($params);
};
$sortArrow = function(string $col) use ($sortCol, $sortDir): string {
if ($sortCol !== $col) return '';
return $sortDir === 'asc' ? ' ↑' : ' ↓';
};
?>
<table>
<thead>
<tr>
<th scope="col"><input type="checkbox" onchange="toggleAll(this)"></th>
<th scope="col">ID</th>
<th scope="col">Titre</th>
<th scope="col"><a href="<?= $sortLink('identifier') ?>" class="admin-sort-link">ID<?= $sortArrow('identifier') ?></a></th>
<th scope="col"><a href="<?= $sortLink('title') ?>" class="admin-sort-link">Titre<?= $sortArrow('title') ?></a></th>
<th scope="col">Auteur(s)</th>
<th scope="col">Année</th>
<th scope="col">Orientation</th>
<th scope="col">AP</th>
<th scope="col">Statut</th>
<th scope="col"><a href="<?= $sortLink('year') ?>" class="admin-sort-link">Année<?= $sortArrow('year') ?></a></th>
<th scope="col"><a href="<?= $sortLink('orientation') ?>" class="admin-sort-link">Orientation<?= $sortArrow('orientation') ?></a></th>
<th scope="col"><a href="<?= $sortLink('ap_program') ?>" class="admin-sort-link">AP<?= $sortArrow('ap_program') ?></a></th>
<th scope="col"><a href="<?= $sortLink('is_published') ?>" class="admin-sort-link">Statut<?= $sortArrow('is_published') ?></a></th>
<th scope="col">Actions</th>
</tr>
</thead>
@@ -422,6 +462,12 @@ document.addEventListener('DOMContentLoaded', () => {
<button type="submit" class="admin-btn-sm admin-btn-publish">Publier</button>
<?php endif; ?>
</form>
<form method="post" action="actions/delete.php" id="delete-form-<?= $thesis['id'] ?>" class="publish-form">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="thesis_id" value="<?= $thesis['id'] ?>">
<button type="button" class="admin-btn-sm admin-btn-delete"
onclick="deleteThesis(<?= $thesis['id'] ?>, <?= htmlspecialchars(json_encode($thesis['title']), ENT_QUOTES) ?>)">Supprimer</button>
</form>
</div>
</td>
</tr>
@@ -436,6 +482,8 @@ document.addEventListener('DOMContentLoaded', () => {
'year' => $yearFilter ?: '',
'orientation' => $orientationFilter ?: '',
'ap' => $apFilter ?: '',
'sort' => $sortCol,
'dir' => $sortDir,
]);
include APP_ROOT . '/templates/partials/pagination.php';
?>

View File

@@ -498,6 +498,20 @@
white-space: nowrap;
}
/* Sortable column headers */
.admin-sort-link {
color: inherit;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 2px;
transition: color 0.15s;
}
.admin-sort-link:hover {
color: var(--accent-primary);
}
.admin-body table td {
padding: var(--space-2xs) var(--space-xs);
border-bottom: 1px solid var(--border-primary);
@@ -621,6 +635,15 @@
background: var(--bg-tertiary);
}
.admin-btn-delete {
background: var(--error-muted-bg);
color: var(--error);
border-color: var(--error-muted-border, var(--border-primary));
}
.admin-btn-delete:hover {
filter: brightness(0.9);
}
.publish-form {
display: inline;
margin: 0;

View File

@@ -661,6 +661,37 @@ class Database {
* @param array $filters
* @return array
*/
/**
* Allowed sort columns for the admin list.
* Maps query-string `sort` values to safe SQL ORDER BY expressions.
*/
private const SORT_MAP = [
'id' => 't.id',
'identifier' => 't.identifier',
'title' => 't.title',
'year' => 't.year',
'orientation' => 'o.name',
'ap_program' => 'ap.name',
'is_published' => 't.is_published',
'submitted_at' => 't.submitted_at',
];
/**
* Build the ORDER BY clause from sort/direction parameters.
* Returns a safe SQL fragment (never interpolates raw user input).
*/
private function buildOrderBy(array $filters): string {
$sort = $filters['sort'] ?? 'submitted_at';
$dir = isset($filters['dir']) && strtolower($filters['dir']) === 'asc' ? 'ASC' : 'DESC';
$col = self::SORT_MAP[$sort] ?? self::SORT_MAP['submitted_at'];
// Secondary sort for stable ordering
$secondary = ($sort === 'year') ? ', t.title ASC' : ', t.id DESC';
return "ORDER BY {$col} {$dir}{$secondary}";
}
/**
* Count theses matching the given admin filters (no LIMIT).
* Used alongside getThesesList() to calculate total pages.
@@ -746,7 +777,8 @@ class Database {
$params[] = intval($filters['ap']);
}
$sql .= " GROUP BY t.id ORDER BY t.year DESC, t.submitted_at DESC";
$orderBy = $this->buildOrderBy($filters);
$sql .= " GROUP BY t.id {$orderBy}";
if ($limit > 0) {
$sql .= " LIMIT :limit OFFSET :offset";
@@ -1560,6 +1592,60 @@ class Database {
return $thesisId;
}
/**
* Delete a single thesis and all its related data (cascade via FK).
* Also removes the banner file from disk if present.
*/
public function deleteThesis(int $thesisId): void {
// Clean up banner file
$bannerPath = $this->getThesisBannerPath($thesisId);
if ($bannerPath !== null) {
$fullPath = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/' . $bannerPath : null;
if ($fullPath && file_exists($fullPath)) {
@unlink($fullPath);
}
}
// Clean up thesis files from disk
$files = $this->getThesisFiles($thesisId);
foreach ($files as $file) {
if (!empty($file['file_path']) && file_exists($file['file_path'])) {
@unlink($file['file_path']);
}
}
// DB cascade handles junction tables
$this->pdo->prepare("DELETE FROM theses WHERE id = ?")->execute([$thesisId]);
}
/**
* Delete multiple theses at once.
* @param int[] $thesisIds
*/
public function bulkDeleteTheses(array $thesisIds): void {
if (empty($thesisIds)) return;
// Clean up files for each thesis
foreach ($thesisIds as $id) {
$bannerPath = $this->getThesisBannerPath($id);
if ($bannerPath !== null) {
$fullPath = defined('STORAGE_ROOT') ? STORAGE_ROOT . '/' . $bannerPath : null;
if ($fullPath && file_exists($fullPath)) {
@unlink($fullPath);
}
}
$files = $this->getThesisFiles($id);
foreach ($files as $file) {
if (!empty($file['file_path']) && file_exists($file['file_path'])) {
@unlink($file['file_path']);
}
}
}
$placeholders = implode(',', array_fill(0, count($thesisIds), '?'));
$this->pdo->prepare("DELETE FROM theses WHERE id IN ($placeholders)")->execute($thesisIds);
}
/**
* Return the stored identifier string (e.g. "2024-003") for an existing thesis.
*/