From 92e344b757cf56a8fa910758f75fa81f52ce73b0 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Tue, 24 Mar 2026 15:35:52 +0100 Subject: [PATCH] feat: admin tag management, maintenance mode, TFE visibility states MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tags admin: - Database: getAllTagsWithCount(), renameTag(), mergeTag(), deleteTag() - public/admin/tags.php: table with inline rename/merge/delete forms, CSRF-guarded - public/admin/actions/tag.php: routes on action=rename|merge|delete - templates/admin/head.php: 'Mots-clés' nav link - admin.css: admin-inline-form, admin-btn--sm/warning/danger variants Maintenance mode: - config/bootstrap.php: gate on MAINTENANCE_FLAG file; admin/ and maintenance.php exempt - public/maintenance.php: 503 dark minimal page - public/admin/actions/maintenance.php: enable/disable toggle - public/admin/index.php: status bar with toggle button - admin.css: admin-maintenance-bar styles TFE Visibility (Libre/Interne/Interdit via existing access_type_id): - migration 002_add_visibility.sql: seeds access_types if missing - Database: setVisibility(), bulkSetVisibility(), getAccessTypes() - public/media.php: blocks thesis files for access_type_id=3 - public/tfe.php: shows access_type, context_note; hides file panel for Interdit - public/admin/edit.php: access_type_id select + context_note textarea; saves both - public/admin/index.php: three-state badge (Libre/Interne/Interdit) per row - public/admin/actions/visibility.php: single + bulk visibility action handler - admin.css: status-access badge variants --- TODO.md | 49 +++++------ config/bootstrap.php | 14 +++ public/admin/actions/maintenance.php | 28 ++++++ public/admin/actions/tag.php | 50 +++++++++++ public/admin/actions/visibility.php | 55 ++++++++++++ public/admin/edit.php | 45 ++++++++-- public/admin/index.php | 28 ++++++ public/admin/tags.php | 97 +++++++++++++++++++++ public/assets/admin.css | 93 ++++++++++++++++++++ public/maintenance.php | 59 +++++++++++++ public/media.php | 32 +++++++ public/tfe.php | 35 +++++++- src/Database.php | 101 ++++++++++++++++++++++ storage/.gitkeep | 0 storage/migrations/002_add_visibility.sql | 9 ++ storage/test.db | Bin 237568 -> 237568 bytes templates/admin/head.php | 1 + 17 files changed, 661 insertions(+), 35 deletions(-) create mode 100644 public/admin/actions/maintenance.php create mode 100644 public/admin/actions/tag.php create mode 100644 public/admin/actions/visibility.php create mode 100644 public/admin/tags.php create mode 100644 public/maintenance.php create mode 100644 storage/.gitkeep create mode 100644 storage/migrations/002_add_visibility.sql diff --git a/TODO.md b/TODO.md index 30d001d..34b994f 100644 --- a/TODO.md +++ b/TODO.md @@ -264,32 +264,25 @@ Goal: rename the tables and column to the canonical M2M pattern (`tags`, `thesis ### 5 — Admin tag management UI (`/admin/tags.php`) -The goal is a dedicated page for viewing, renaming, merging, and deleting tags, with -full referential-integrity awareness (no orphan `thesis_tags` rows, no broken search -results). +#### 5a — `src/Database.php` -#### 5a — `src/Database.php` — new tag-management methods +- [x] `getAllTagsWithCount()`, `renameTag()`, `mergeTag()`, `deleteTag()` -- [ ] `getAllTagsWithCount(): array` — return all tags with a `thesis_count` column -- [ ] `renameTag(int $id, string $newName): void` -- [ ] `mergeTag(int $sourceId, int $targetId): void` -- [ ] `deleteTag(int $id): void` +#### 5b — `public/admin/tags.php` -#### 5b — `public/admin/tags.php` — list + inline-edit view +- [x] Auth guard, CSRF, table with rename/merge/delete per row, inline forms -- [ ] Auth guard, CSRF, table with rename/merge/delete per row +#### 5c — `public/admin/actions/tag.php` -#### 5c — `public/admin/actions/tag.php` — POST action handler - -- [ ] Route on `$_POST['action']`: rename, merge, delete +- [x] Routes on `$_POST['action']`: rename, merge, delete #### 5d — Nav & routing -- [ ] `templates/admin/head.php`: add nav link to `/admin/tags.php` +- [x] `templates/admin/head.php`: "Mots-clés" nav link added -#### 5e — Propagation safety checklist +#### 5e — Propagation safety -- [ ] Verify all search/display paths remain correct after tag ops +- [x] mergeTag() uses INSERT OR IGNORE to avoid PK conflicts; deleteTag() cascades via FK ### 6 — Tests @@ -304,23 +297,23 @@ results). ## Feature: Mode Maintenance -- [ ] Storage flag file `storage/maintenance.flag` -- [ ] Public gate in `config/bootstrap.php` -- [ ] `public/maintenance.php` (503 page) -- [ ] `public/admin/actions/maintenance.php` (POST handler) -- [ ] Admin UI toggle in `public/admin/index.php` +- [x] Storage flag file `storage/maintenance.flag` (created on demand) +- [x] Public gate in `config/bootstrap.php` — blocks non-admin routes when flag exists +- [x] `public/maintenance.php` (503 page, minimal dark UI) +- [x] `public/admin/actions/maintenance.php` (POST: enable/disable) +- [x] Admin UI toggle in `public/admin/index.php` (bar with status + action button) --- ## Feature: TFE Visibility States (publique / interne / interdit) -- [ ] DB migration `002_add_visibility.sql` -- [ ] `src/Database.php` — `setVisibility()`, `bulkSetVisibility()` -- [ ] `public/media.php` — visibility gate -- [ ] `public/tfe.php` — conditional rendering -- [ ] `public/admin/edit.php` — visibility select + context_note textarea -- [ ] `public/admin/index.php` — three-state badge + bulk actions -- [ ] `public/admin/actions/publish.php` or new `visibility.php` +- [x] DB migration `002_add_visibility.sql` — seeds access_types rows (already existed) +- [x] `src/Database.php` — `setVisibility()`, `bulkSetVisibility()`, `getAccessTypes()` +- [x] `public/media.php` — blocks thesis files when access_type_id = 3 (Interdit) +- [x] `public/tfe.php` — shows access type, context_note, hides files for Interdit +- [x] `public/admin/edit.php` — access_type_id select + context_note textarea +- [x] `public/admin/index.php` — three-state access badge per row +- [x] `public/admin/actions/visibility.php` — single + bulk visibility update --- diff --git a/config/bootstrap.php b/config/bootstrap.php index 54ddfeb..886ce5c 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -28,3 +28,17 @@ if (php_sapi_name() === 'cli-server') { if (file_exists(APP_ROOT . '/config/admin_credentials.php')) { require_once APP_ROOT . '/config/admin_credentials.php'; } + +// Maintenance mode gate — block public pages; allow /admin/ through. +// The flag file lives in storage/ (outside webroot) to avoid web exposure. +define('MAINTENANCE_FLAG', APP_ROOT . '/storage/maintenance.flag'); +if (file_exists(MAINTENANCE_FLAG)) { + // Allow admin panel through (by path prefix) and the maintenance page itself + $requestPath = $_SERVER['REQUEST_URI'] ?? ''; + $isAdmin = str_starts_with($requestPath, '/admin'); + $isMaintenance = str_contains($requestPath, 'maintenance.php'); + if (!$isAdmin && !$isMaintenance) { + require APP_ROOT . '/public/maintenance.php'; + exit(); + } +} diff --git a/public/admin/actions/maintenance.php b/public/admin/actions/maintenance.php new file mode 100644 index 0000000..9199b95 --- /dev/null +++ b/public/admin/actions/maintenance.php @@ -0,0 +1,28 @@ +renameTag($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->mergeTag($sourceId, $targetId); + break; + + case 'delete': + $id = filter_var($_POST['tag_id'] ?? '', FILTER_VALIDATE_INT); + if (!$id) throw new Exception("ID invalide."); + $db->deleteTag($id); + break; + + default: + throw new Exception("Action inconnue."); + } + + $_SESSION['admin_success'] = "Opération effectuée."; +} catch (Exception $e) { + $_SESSION['admin_error'] = $e->getMessage(); +} + +header('Location: /admin/tags.php'); +exit(); diff --git a/public/admin/actions/visibility.php b/public/admin/actions/visibility.php new file mode 100644 index 0000000..73e3650 --- /dev/null +++ b/public/admin/actions/visibility.php @@ -0,0 +1,55 @@ + $id > 0); + if (empty($ids)) { + $_SESSION['error'] = "Aucun TFE sélectionné."; + header('Location: /admin/'); + exit; + } + $db->bulkSetVisibility($ids, $accessTypeId); + $_SESSION['success'] = count($ids) . " TFE(s) mis à jour."; + } else { + $thesisId = filter_var($_POST['thesis_id'] ?? '', FILTER_VALIDATE_INT); + if (!$thesisId) { + $_SESSION['error'] = "ID invalide."; + header('Location: /admin/'); + exit; + } + $db->setVisibility($thesisId, $accessTypeId); + $_SESSION['success'] = "Visibilité mise à jour."; + } +} catch (Exception $e) { + error_log("visibility.php error: " . $e->getMessage()); + $_SESSION['error'] = "Erreur : " . $e->getMessage(); +} + +$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); +header('Location: /admin/'); +exit; diff --git a/public/admin/edit.php b/public/admin/edit.php index eb20718..1562721 100644 --- a/public/admin/edit.php +++ b/public/admin/edit.php @@ -36,7 +36,9 @@ try { $db->beginTransaction(); // Update thesis basic info - $editLicenseId = filter_var($_POST['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null; + $editLicenseId = filter_var($_POST['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null; + $editAccessTypeId = filter_var($_POST['access_type_id'] ?? '', FILTER_VALIDATE_INT) ?: null; + $editContextNote = trim($_POST['context_note'] ?? ''); $stmt = $pdo->prepare(" UPDATE theses SET @@ -47,9 +49,11 @@ try { ap_program_id = ?, finality_id = ?, synopsis = ?, + context_note = ?, file_size_info = ?, baiu_link = ?, license_id = ?, + access_type_id = ?, is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? @@ -63,9 +67,11 @@ try { intval($_POST['ap']), intval($_POST['finality']), trim($_POST['synopsis']), + !empty($editContextNote) ? $editContextNote : null, !empty($_POST['duration_info']) ? trim($_POST['duration_info']) : null, !empty($_POST['lien']) ? trim($_POST['lien']) : null, $editLicenseId, + $editAccessTypeId, isset($_POST['is_published']) ? 1 : 0, $thesisId ]); @@ -208,11 +214,15 @@ try { $languages = $db->getAllLanguages(); $formatTypes = $db->getAllFormatTypes(); $licenseTypes = $db->getAllLicenseTypes(); + $accessTypes = $db->getAccessTypes(); - // Fetch raw license_id FK (view only exposes license_type name string) - $licenseStmt = $pdo->prepare("SELECT license_id FROM theses WHERE id = ?"); - $licenseStmt->execute([$thesisId]); - $currentLicenseId = $licenseStmt->fetchColumn(); + // Fetch raw FK IDs (view only exposes name strings) + $rawStmt = $pdo->prepare("SELECT license_id, access_type_id, context_note FROM theses WHERE id = ?"); + $rawStmt->execute([$thesisId]); + $rawRow = $rawStmt->fetch(); + $currentLicenseId = $rawRow['license_id'] ?? null; + $currentAccessTypeId = $rawRow['access_type_id'] ?? null; + $currentContextNote = $rawRow['context_note'] ?? ''; // Set page title for header $pageTitle = "Éditer TFE - " . htmlspecialchars($thesis['title']); @@ -380,6 +390,31 @@ try { } +
+ + +
+ +
+ +
+ +

Visible publiquement pour les TFE Interne ou Interdit. Max 1 500 caractères.

+
+
+
+ + + + + Site public : en ligne +
+ + + +
+ +
+
@@ -167,6 +190,11 @@ document.addEventListener('DOMContentLoaded', () => { En attente + +
+ + +
diff --git a/public/admin/tags.php b/public/admin/tags.php new file mode 100644 index 0000000..235270b --- /dev/null +++ b/public/admin/tags.php @@ -0,0 +1,97 @@ +getAllTagsWithCount(); +} catch (Exception $e) { + die("Erreur : " . htmlspecialchars($e->getMessage())); +} + +$error = $_SESSION['admin_error'] ?? null; +$success = $_SESSION['admin_success'] ?? null; +unset($_SESSION['admin_error'], $_SESSION['admin_success']); +?> + + +
+

Mots-clés ()

+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + +
NomTFE associésActions
+ +
+ + + + + +
+ + +
+ + + + + +
+ + +
+ + + + +
+
+
+ + diff --git a/public/assets/admin.css b/public/assets/admin.css index 0363ce0..a1f12ff 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -641,3 +641,96 @@ html, body { border-color: #e55; color: #e55; } + +/* Inline form actions (tags page) */ +.admin-inline-form { + display: flex; + align-items: center; + gap: .4rem; + flex-wrap: wrap; +} + +.admin-input--inline { + padding: .28rem .5rem; + font-size: .82rem; +} + +.admin-select--inline { + padding: .28rem .5rem; + font-size: .82rem; +} + +.admin-btn--sm { + padding: .28rem .65rem; + font-size: .8rem; +} + +.admin-btn--warning { + background: #7a5400; + border-color: #b87a00; +} +.admin-btn--warning:hover { + background: #9a6a00; +} + +.admin-btn--danger { + background: #6b1a1a; + border-color: #a03030; +} +.admin-btn--danger:hover { + background: #8a2020; +} + +/* Maintenance mode bar */ +.admin-maintenance-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + background: #1a1a2e; + border: 1px solid #333; + border-radius: 4px; + padding: .65rem 1rem; + margin-bottom: 1.5rem; + font-size: .88rem; + color: var(--admin-text-muted); +} + +.admin-maintenance-bar--active { + background: #2a1a00; + border-color: #7a5400; + color: #e0a030; +} + +/* Visibility / access badges */ +.status-access { + display: inline-block; + font-size: .7rem; + padding: .1rem .4rem; + border-radius: 3px; + margin-top: .2rem; + background: #333; + color: #aaa; + letter-spacing: .03em; +} + +.status-access--Libre, +.status-access--libre { + background: #0a2a0a; + color: #6fbe6f; + border: 1px solid #2a5a2a; +} + +.status-access--Interne, +.status-access--interne { + background: #1a1a2a; + color: #7a8fcc; + border: 1px solid #3a4a8a; +} + +.status-access--Interdit, +.status-access--interdit { + background: #2a0a0a; + color: #cc6060; + border: 1px solid #7a2020; +} diff --git a/public/maintenance.php b/public/maintenance.php new file mode 100644 index 0000000..f9abbea --- /dev/null +++ b/public/maintenance.php @@ -0,0 +1,59 @@ + + + + + + Maintenance – Posterg + + + +
+ +

Maintenance en cours

+

+ Le site est temporairement indisponible pour des raisons de maintenance.
+ Merci de réessayer dans quelques instants. +

+
+ + diff --git a/public/media.php b/public/media.php index d2fceed..655748e 100644 --- a/public/media.php +++ b/public/media.php @@ -46,6 +46,38 @@ if (!is_file($realFull)) { exit; } +// --- 2b. Visibility gate: check access_type_id for thesis files --------------- +// Only applies to paths under theses/ (uploaded thesis content). +// Banners and covers are always public (no sensitivity). +if (preg_match('#^theses/#', $requestedPath)) { + require_once __DIR__ . '/../src/Database.php'; + try { + $mediaDb = Database::getInstance(); + $mediaPdo = $mediaDb->getConnection(); + // Find the thesis that owns this file path + $visStmt = $mediaPdo->prepare(" + SELECT t.access_type_id FROM theses t + JOIN thesis_files tf ON tf.thesis_id = t.id + WHERE tf.file_path = ? + LIMIT 1 + "); + $visStmt->execute([$requestedPath]); + $visRow = $visStmt->fetch(); + if ($visRow) { + $accessTypeId = (int)($visRow['access_type_id'] ?? 1); + // 3 = Interdit — block entirely + if ($accessTypeId === 3) { + http_response_code(403); + exit; + } + // 2 = Interne — allow (no session auth requirement for now; could add later) + } + } catch (\Throwable $e) { + // On DB error, fail open (don't block legitimate requests) + error_log("media.php visibility check error: " . $e->getMessage()); + } +} + // --- 3. Verify MIME type from file content (not extension) -------------------- $finfo = new finfo(FILEINFO_MIME_TYPE); diff --git a/public/tfe.php b/public/tfe.php index 5343700..9e44183 100644 --- a/public/tfe.php +++ b/public/tfe.php @@ -128,6 +128,13 @@ $currentNav = '';
+ +
+ Accès : + +
+ +
Licence : @@ -135,6 +142,13 @@ $currentNav = '';
+ +
+ Note : + +
+ +
Contact : @@ -162,7 +176,24 @@ $currentNav = '';
- + getConnection()->prepare( + "SELECT access_type_id FROM theses WHERE id = ?" + ); + $accessStmt->execute([$thesisId]); + $accessTypeId = (int)($accessStmt->fetchColumn() ?? 1); + } catch (\Throwable $e) {} + $isInterdit = ($accessTypeId === 3); + ?> + +

+ Ce TFE n'est pas disponible en ligne. +

+
@@ -184,7 +215,7 @@ $currentNav = '';

Aucun fichier disponible pour ce TFE.

- +
diff --git a/src/Database.php b/src/Database.php index 384a466..06c7ab3 100644 --- a/src/Database.php +++ b/src/Database.php @@ -640,6 +640,69 @@ class Database { return $this->findOrCreateTag((string)$keyword); } + // ======================================================================== + // TAG MANAGEMENT (admin) + // ======================================================================== + + /** + * Return all tags with a count of associated (published) theses. + */ + public function getAllTagsWithCount(): array { + $stmt = $this->pdo->query(" + SELECT tg.id, tg.name, + COUNT(DISTINCT tt.thesis_id) as thesis_count + FROM tags tg + LEFT JOIN thesis_tags tt ON tg.id = tt.tag_id + GROUP BY tg.id + ORDER BY tg.name COLLATE NOCASE + "); + return $stmt->fetchAll(); + } + + /** + * Rename a tag. Throws if the new name already exists. + */ + public function renameTag(int $id, string $newName): void { + $newName = trim($newName); + if ($newName === '') throw new Exception("Le nom du tag ne peut pas être vide."); + // Check uniqueness + $stmt = $this->pdo->prepare("SELECT id FROM tags WHERE name = ? AND id != ?"); + $stmt->execute([$newName, $id]); + if ($stmt->fetch()) throw new Exception("Un tag avec ce nom existe déjà."); + $this->pdo->prepare("UPDATE tags SET name = ? WHERE id = ?")->execute([$newName, $id]); + } + + /** + * Merge sourceId into targetId: reassign all thesis_tags rows, then delete source. + * Uses INSERT OR IGNORE to avoid PK conflicts. + */ + public function mergeTag(int $sourceId, int $targetId): void { + if ($sourceId === $targetId) throw new Exception("Source et destination identiques."); + $this->pdo->beginTransaction(); + try { + // Re-point thesis_tags rows from source → target (skip conflicts) + $this->pdo->prepare(" + INSERT OR IGNORE INTO thesis_tags (tag_id, thesis_id) + SELECT ?, thesis_id FROM thesis_tags WHERE tag_id = ? + ")->execute([$targetId, $sourceId]); + // Delete the old source rows + $this->pdo->prepare("DELETE FROM thesis_tags WHERE tag_id = ?")->execute([$sourceId]); + // Delete the source tag itself + $this->pdo->prepare("DELETE FROM tags WHERE id = ?")->execute([$sourceId]); + $this->pdo->commit(); + } catch (\Throwable $e) { + $this->pdo->rollBack(); + throw $e; + } + } + + /** + * Delete a tag and all its thesis_tags rows (cascades via FK). + */ + public function deleteTag(int $id): void { + $this->pdo->prepare("DELETE FROM tags WHERE id = ?")->execute([$id]); + } + /** * Get orientation ID by name */ @@ -749,6 +812,44 @@ class Database { return $this->getLicenseTypes(); } + // ======================================================================== + // VISIBILITY METHODS + // ======================================================================== + + /** + * Set the access_type_id (visibility) for a single thesis. + * @param int $thesisId + * @param int|null $accessTypeId 1=Libre, 2=Interne, 3=Interdit, null=unset + */ + public function setVisibility(int $thesisId, ?int $accessTypeId): void { + $stmt = $this->pdo->prepare( + "UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" + ); + $stmt->execute([$accessTypeId, $thesisId]); + } + + /** + * Set visibility for multiple theses at once. + * @param int[] $thesisIds + * @param int|null $accessTypeId + */ + public function bulkSetVisibility(array $thesisIds, ?int $accessTypeId): void { + if (empty($thesisIds)) return; + $placeholders = implode(',', array_fill(0, count($thesisIds), '?')); + $params = array_merge([$accessTypeId], $thesisIds); + $this->pdo->prepare( + "UPDATE theses SET access_type_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN ($placeholders)" + )->execute($params); + } + + /** + * Get all access types (visibility options). + */ + public function getAccessTypes(): array { + $stmt = $this->pdo->query("SELECT * FROM access_types ORDER BY id"); + return $stmt->fetchAll(); + } + // ======================================================================== // JURY METHODS // ======================================================================== diff --git a/storage/.gitkeep b/storage/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/storage/migrations/002_add_visibility.sql b/storage/migrations/002_add_visibility.sql new file mode 100644 index 0000000..a2ff229 --- /dev/null +++ b/storage/migrations/002_add_visibility.sql @@ -0,0 +1,9 @@ +-- Migration 002: Wire visibility states to existing access_type_id column. +-- The access_types table already has Libre (1), Interne (2), Interdit (3). +-- No structural changes needed — this migration is a no-op for the schema. +-- It documents the intent and ensures access_types seed rows exist. + +INSERT OR IGNORE INTO access_types (id, name, description) VALUES + (1, 'Libre', 'TFE en libre accès à tout le monde sur la plateforme et en bibliothèque'), + (2, 'Interne', 'TFE accessible uniquement sur place en physique. Une note descriptive est disponible sur le site'), + (3, 'Interdit', 'TFE non disponible en physique ni sur le site. Une note descriptive est disponible sur le site'); diff --git a/storage/test.db b/storage/test.db index 4c557189a6f4a90520e6f8545cc43580c8ff8401..8f2497aac5b07ca45ca7b7fb6af20d0302e58db0 100644 GIT binary patch delta 245 zcmZoTz}IkqZ-O-Ax`{H*jO!W`S`!$zCNM2rug?F8L4bcX1OHwA8~m5~&+(t+Kf=GC ze;5B&{tf(p_*e6P=YPxpi2wO!!3oX$tSro&j7-xv>}PV~VqjokWdQM+xBuDC#FZdm z17tBiWU%3L;F-YnoOSJFLjg5~W+i4>23czcCJshM##UyaCI$vh1_ll=Wo*o_eUlb* z1*4#`u91m?p^25Tsg{|5d){NMRM@xSGN#Q%J=;DiqT>8sW=iA+DTpGj}}r+Ox#?JNhFm=XjW82Gq( z%(*6WTxM5htzvMPY$%|$eXS~UFc(X+BD2hN2L)#7?J)|>6^uHDx<(cXhUQiVCRPT9 z&4ElZ46^3dOdO1ioUPVChcGa3GB9v}DP!a9j7rQa7zL5F7&I3#%1qy_3^eb$GV@7B LUKGh?2FwKj7AHSm diff --git a/templates/admin/head.php b/templates/admin/head.php index d96266b..d796e44 100644 --- a/templates/admin/head.php +++ b/templates/admin/head.php @@ -28,6 +28,7 @@ Ajouter un TFE Importer une liste de TFE Pages statiques + Mots-clés Modifier