diff --git a/TODO.md b/TODO.md
index 5ab051d..6f8a002 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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)
diff --git a/public/admin/actions/delete.php b/public/admin/actions/delete.php
new file mode 100644
index 0000000..358e1ae
--- /dev/null
+++ b/public/admin/actions/delete.php
@@ -0,0 +1,55 @@
+ $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;
diff --git a/public/admin/index.php b/public/admin/index.php
index adc8062..23143c4 100644
--- a/public/admin/index.php
+++ b/public/admin/index.php
@@ -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';
- if (!confirm(`${word.charAt(0).toUpperCase()+word.slice(1)} ${checked.length} TFE(s) ?`)) return;
+ 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', () => {
+
@@ -371,17 +391,37 @@ document.addEventListener('DOMContentLoaded', () => {
}
?>
+ $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' ? ' ↑' : ' ↓';
+ };
+ ?>
|
- ID |
- Titre |
+ ID= $sortArrow('identifier') ?> |
+ Titre= $sortArrow('title') ?> |
Auteur(s) |
- Année |
- Orientation |
- AP |
- Statut |
+ Année= $sortArrow('year') ?> |
+ Orientation= $sortArrow('orientation') ?> |
+ AP= $sortArrow('ap_program') ?> |
+ Statut= $sortArrow('is_published') ?> |
Actions |
@@ -422,6 +462,12 @@ document.addEventListener('DOMContentLoaded', () => {
+
@@ -436,6 +482,8 @@ document.addEventListener('DOMContentLoaded', () => {
'year' => $yearFilter ?: '',
'orientation' => $orientationFilter ?: '',
'ap' => $apFilter ?: '',
+ 'sort' => $sortCol,
+ 'dir' => $sortDir,
]);
include APP_ROOT . '/templates/partials/pagination.php';
?>
diff --git a/public/assets/css/admin.css b/public/assets/css/admin.css
index 5857fa3..df19404 100644
--- a/public/assets/css/admin.css
+++ b/public/assets/css/admin.css
@@ -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;
diff --git a/src/Database.php b/src/Database.php
index 8db1712..f9da3e3 100644
--- a/src/Database.php
+++ b/src/Database.php
@@ -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.
*/