diff --git a/TODO.md b/TODO.md
index 8f0f64a..b7c4934 100644
--- a/TODO.md
+++ b/TODO.md
@@ -113,8 +113,7 @@ no seed data yet.
#### 3f — View update
-- [ ] **`storage/schema.sql`** — update `v_theses_full` to expose `t.license_id` raw FK
- (edit form currently queries theses directly — lower priority)
+- [x] **`storage/schema.sql`** — `v_theses_full` now exposes `t.license_id` raw FK (done as part of 004/005 view rebuild)
#### 3g — TFE public page
@@ -133,90 +132,43 @@ internal/external).
Current state: `supervisors` table + `thesis_supervisors` junction with a bare
`supervisor_order` integer. No role distinction.
-- [ ] **`storage/migrations/004_jury_roles.sql`**:
+- [x] **`storage/migrations/004_jury_roles.sql`**:
- `ALTER TABLE thesis_supervisors ADD COLUMN role TEXT NOT NULL DEFAULT 'promoteur'`
— role values: `'president'`, `'promoteur'`, `'lecteur'`
- `ALTER TABLE thesis_supervisors ADD COLUMN is_external INTEGER NOT NULL DEFAULT 0`
— 1 = external, 0 = internal (replaces the old "promoteur externe" free-text field)
- No data loss: existing rows get `role = 'promoteur'`, `is_external = 0`
-- [ ] **`storage/schema.sql`** — add the two new columns to `thesis_supervisors`
+- [x] **`storage/schema.sql`** — add the two new columns to `thesis_supervisors`
definition; update `v_theses_full` to expose jury members grouped by role:
- `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='president' THEN s.name END) as jury_president`
- - `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='promoteur' THEN s.name END) as promoteurs`
- - `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='lecteur' THEN s.name END) as lecteurs`
+ - `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='promoteur' THEN s.name END) as jury_promoteurs`
+ - `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='lecteur' THEN s.name END) as jury_lecteurs`
- Keep the existing `supervisors` column (all names) for backwards compat
- Migration SQL must DROP + CREATE the view
#### 4b — `src/Database.php`
-- [ ] `getThesisJury(int $thesisId): array` — fetch all supervisors for a thesis with
- their role and is_external flag:
- ```sql
- SELECT s.id, s.name, ts.role, ts.is_external, ts.supervisor_order
- FROM thesis_supervisors ts
- JOIN supervisors s ON s.id = ts.supervisor_id
- WHERE ts.thesis_id = ?
- ORDER BY ts.supervisor_order
- ```
-- [ ] `setThesisJury(int $thesisId, array $juryMembers): void` — within a transaction:
- 1. `DELETE FROM thesis_supervisors WHERE thesis_id = ?`
- 2. For each member `[name, role, is_external]`:
- - `findOrCreateSupervisor($name)`
- - `INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, supervisor_order)`
+- [x] `getThesisJury(int $thesisId): array` — fetch all supervisors for a thesis with
+ their role and is_external flag
+- [x] `setThesisJury(int $thesisId, array $juryMembers): void` — delete + re-insert
#### 4c — Add form (`public/admin/add.php`)
-Remove the following existing fields:
-- [ ] Remove `promoteurice` (single internal promoter text input)
-- [ ] Remove `promoteurice_externe` (single external promoter text input)
-
-Add a new **"Composition du jury"** fieldset section:
-- [ ] **Président·e** — single text input `name="jury_president"` (one person, always internal)
-- [ ] **Promoteur·ice** — single text input `name="jury_promoteur"` with checkbox
- `name="jury_promoteur_ext"` value="1" → marks as external
-- [ ] **Lecteur·ices** — dynamic list of up to N entries; each row has:
- - text input `name="jury_lecteurs[]"` (person name)
- - checkbox `name="jury_lecteurs_ext[]"` with matching index → "Externe"
- - "Ajouter un·e lecteur·ice" button (JS adds a new row)
- - "Supprimer" button per row (JS removes row)
- - Minimum 0 lecteurs (field is optional)
+- [x] Remove `promoteurice` and `promoteurice_externe` fields
+- [x] Add **"Composition du jury"** fieldset: président·e, promoteur·ice + externe checkbox, dynamic lecteur·ices list with JS add/remove
#### 4d — Add action (`public/admin/actions/formulaire.php`)
-- [ ] Remove parsing of `$_POST['promoteurice']` and `$_POST['promoteurice_externe']`
-- [ ] Parse new jury fields:
- ```php
- $juryMembers = [];
- if (!empty($_POST['jury_president'])) {
- $juryMembers[] = ['name' => trim($_POST['jury_president']), 'role' => 'president', 'is_external' => 0];
- }
- if (!empty($_POST['jury_promoteur'])) {
- $juryMembers[] = ['name' => trim($_POST['jury_promoteur']), 'role' => 'promoteur',
- 'is_external' => isset($_POST['jury_promoteur_ext']) ? 1 : 0];
- }
- foreach ($_POST['jury_lecteurs'] ?? [] as $i => $name) {
- if (!empty(trim($name))) {
- $juryMembers[] = ['name' => trim($name), 'role' => 'lecteur',
- 'is_external' => isset($_POST['jury_lecteurs_ext'][$i]) ? 1 : 0];
- }
- }
- ```
-- [ ] Replace the supervisor insertion loop with `$db->setThesisJury($thesisId, $juryMembers)`
+- [x] Removed old promoteurice parsing; parse jury fields; call `$db->setThesisJury()`
#### 4e — Edit form (`public/admin/edit.php`)
-- [ ] Load `$jury = $db->getThesisJury($thesisId)` alongside other data
-- [ ] Replace old `promoteurice` text input with the same **"Composition du jury"**
- fieldset as in add.php; pre-populate fields from `$jury` array
-- [ ] In POST handler: remove old supervisor logic; call `$db->setThesisJury()`
+- [x] Load `$jury = $db->getThesisJury($thesisId)`; jury fieldset pre-populated from DB
+- [x] POST handler calls `$db->setThesisJury()`
#### 4f — TFE public page (`public/tfe.php`)
-- [ ] Replace "Promoteur·ice interne :" single meta row with three conditional rows:
- - "Président·e du jury :" — `$data['jury_president']` (from updated view)
- - "Promoteur·ice :" — `$data['promoteurs']` (from updated view)
- - "Lecteur·ices :" — `$data['lecteurs']` (from updated view)
- - Each row hidden if null/empty
+- [x] Three conditional jury rows: Président·e, Promoteur·ice, Lecteur·ices
---
@@ -229,48 +181,26 @@ home grid (wider, shorter aspect ratio).
#### 5a — Schema / DB
-- [ ] **`storage/migrations/005_add_banner.sql`**:
- - `ALTER TABLE theses ADD COLUMN banner_path TEXT` — stores path relative to
- `STORAGE_ROOT`, same convention as `thesis_files.file_path`; NULL if no banner
-- [ ] **`storage/schema.sql`** — add `banner_path TEXT` column to `theses` definition;
- update `v_theses_full` to `SELECT t.banner_path`
+- [x] **`storage/migrations/005_add_banner.sql`** — `ALTER TABLE theses ADD COLUMN banner_path TEXT`
+- [x] **`storage/schema.sql`** — `banner_path TEXT` in `theses`; `t.banner_path` in view
#### 5b — Add form (`public/admin/add.php`)
-- [ ] Add "Image bannière (page d'accueil)" file input row:
- ```html
-
- ```
-- [ ] Hint: "JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB."
-- [ ] Placed after the existing "Image de couverture" row
+- [x] "Image bannière" file input added after couverture row
#### 5c — Add action (`public/admin/actions/formulaire.php`)
-- [ ] Process `$_FILES['banner']` similarly to `$_FILES['couverture']`:
- - Allowed MIME: `image/jpeg`, `image/png`, `image/webp`
- - Max size: 5 MB
- - Save to `STORAGE_ROOT . "/banners/"` with a random hex filename
- - Path stored as `"banners/" . $safeFileName`
-- [ ] After thesis INSERT: `UPDATE theses SET banner_path = ? WHERE id = ?`
- (or include `banner_path` in the initial INSERT column list)
+- [x] Banner upload: MIME check, 5 MB cap, save to `banners/`, call `setBannerPath()`
#### 5d — Edit form (`public/admin/edit.php`)
-- [ ] Display existing banner as `` preview if `$thesis['banner_path']` is set
- (served via `/media.php?path=…`)
-- [ ] Add file input `name="banner"` to replace/upload new banner
-- [ ] Add checkbox `name="remove_banner"` to clear the current banner
-- [ ] In POST handler:
- - If `remove_banner` checked: `UPDATE theses SET banner_path = NULL WHERE id = ?`;
- also unlink the file
- - If new file uploaded: process as in 5c; update `banner_path`
+- [x] Banner preview img shown; remove_banner checkbox; new banner upload input
+- [x] POST handler: unlinks old file on remove; processes new upload via `setBannerPath()`
#### 5e — `src/Database.php`
-- [ ] `getThesis()` and `getThesisById()` — already return all columns from the view;
- after adding `banner_path` to the view, they automatically expose it — verify
-- [ ] `getPublishedTheses()` — same: view-sourced, automatic after view update
-- [ ] Optionally add `setBannerPath(int $thesisId, ?string $path): void` for clarity
+- [x] `banner_path` exposed via view — verified; `getThesisById()` / `getPublishedTheses()` pick it up automatically
+- [x] `setBannerPath(int $thesisId, ?string $path): void` added
---
diff --git a/public/admin/actions/formulaire.php b/public/admin/actions/formulaire.php
index 241a16c..f0b02fb 100644
--- a/public/admin/actions/formulaire.php
+++ b/public/admin/actions/formulaire.php
@@ -84,9 +84,22 @@ try {
$problematique = sanitize_string($_POST["problématique"] ?? '');
$durationInfo = sanitize_string($_POST["duration_info"] ?? '');
- // Supervisor(s)
- $promoteuriceRaw = sanitize_string($_POST["promoteurice"] ?? '');
- $supervisorNames = !empty($promoteuriceRaw) ? array_map('trim', explode(',', $promoteuriceRaw)) : [];
+ // Jury members
+ $juryMembers = [];
+ if (!empty(trim($_POST['jury_president'] ?? ''))) {
+ $juryMembers[] = ['name' => trim($_POST['jury_president']), 'role' => 'president', 'is_external' => 0];
+ }
+ if (!empty(trim($_POST['jury_promoteur'] ?? ''))) {
+ $juryMembers[] = ['name' => trim($_POST['jury_promoteur']), 'role' => 'promoteur',
+ 'is_external' => isset($_POST['jury_promoteur_ext']) ? 1 : 0];
+ }
+ foreach ($_POST['jury_lecteurs'] ?? [] as $i => $name) {
+ $name = trim($name);
+ if ($name !== '') {
+ $juryMembers[] = ['name' => $name, 'role' => 'lecteur',
+ 'is_external' => isset($_POST['jury_lecteurs_ext'][$i]) ? 1 : 0];
+ }
+ }
// Keywords (max 10)
$tagRaw = sanitize_string($_POST["tag"] ?? '');
@@ -119,6 +132,7 @@ try {
// File uploads
$couverture = $_FILES["couverture"] ?? null;
+ $bannerFile = $_FILES["banner"] ?? null;
$files = $_FILES["files"] ?? null;
// ===== CREATE OR FIND AUTHOR =====
@@ -164,14 +178,8 @@ try {
$stmt = $pdo->prepare("INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?, ?, 1)");
$stmt->execute([$thesisId, $authorId]);
- // ===== LINK SUPERVISORS TO THESIS =====
- foreach ($supervisorNames as $index => $supervisorName) {
- if (!empty($supervisorName)) {
- $supervisorId = $db->findOrCreateSupervisor($supervisorName);
- $stmt = $pdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, supervisor_order) VALUES (?, ?, ?)");
- $stmt->execute([$thesisId, $supervisorId, $index + 1]);
- }
- }
+ // ===== LINK JURY TO THESIS =====
+ $db->setThesisJury($thesisId, $juryMembers);
// ===== LINK LANGUAGES TO THESIS =====
foreach ($languageIds as $languageId) {
@@ -201,7 +209,8 @@ try {
// Create necessary directories — outside the webroot (security items #3 & #4).
// Files are served through /media.php, never directly via a URL path.
$uploadBaseDir = STORAGE_ROOT . "/theses/{$annee}/{$identifier}/";
- $coverDir = STORAGE_ROOT . "/covers/";
+ $coverDir = STORAGE_ROOT . "/covers/";
+ $bannerDir = STORAGE_ROOT . "/banners/";
if (!file_exists($uploadBaseDir)) {
mkdir($uploadBaseDir, 0755, true);
@@ -209,6 +218,9 @@ try {
if (!file_exists($coverDir)) {
mkdir($coverDir, 0755, true);
}
+ if (!file_exists($bannerDir)) {
+ mkdir($bannerDir, 0755, true);
+ }
// Define security constraints
$allowedMimeTypes = ['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'application/zip'];
@@ -252,6 +264,30 @@ try {
}
}
+ // Process banner image
+ if ($bannerFile && isset($bannerFile["error"]) && $bannerFile["error"] === UPLOAD_ERR_OK) {
+ $finfo = new finfo(FILEINFO_MIME_TYPE);
+ $mimeType = $finfo->file($bannerFile["tmp_name"]);
+ $fileExtension = strtolower(pathinfo($bannerFile["name"], PATHINFO_EXTENSION));
+ $allowedBannerMimes = ['image/jpeg', 'image/png', 'image/webp'];
+ $allowedBannerExts = ['jpg', 'jpeg', 'png', 'webp'];
+ $maxBannerSize = 5 * 1024 * 1024; // 5 MB
+
+ if (in_array($mimeType, $allowedBannerMimes) && in_array($fileExtension, $allowedBannerExts)
+ && $bannerFile["size"] <= $maxBannerSize) {
+ $randomName = bin2hex(random_bytes(16));
+ $safeFileName = $randomName . "." . $fileExtension;
+ $targetFile = $bannerDir . $safeFileName;
+ if (move_uploaded_file($bannerFile["tmp_name"], $targetFile)) {
+ chmod($targetFile, 0644);
+ $db->setBannerPath($thesisId, "banners/" . $safeFileName);
+ error_log("Banner image uploaded: " . $safeFileName);
+ }
+ } else {
+ error_log("Invalid or oversized banner image: " . $bannerFile["name"]);
+ }
+ }
+
// Process thesis files
if ($files && is_array($files["name"])) {
for ($i = 0; $i < count($files["name"]); $i++) {
diff --git a/public/admin/add.php b/public/admin/add.php
index e9b0675..a853187 100644
--- a/public/admin/add.php
+++ b/public/admin/add.php
@@ -79,19 +79,67 @@ function wasSelected($key, $value) {
value="= old('mail') ?>">
-
-
JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB.
+