docs: add feature tasks for licence page, admin WYSIWYG, jury section, banner upload, and home randomisation

This commit is contained in:
Pontoporeia
2026-03-24 12:55:22 +01:00
parent f8a4bfb612
commit 86a2082edc

548
TODO.md
View File

@@ -33,6 +33,350 @@
- [x] Guard `deploy-db` against overwriting existing remote database - [x] Guard `deploy-db` against overwriting existing remote database
- [x] Update README.md and docs/SERVER_SETUP.md to reflect current structure - [x] Update README.md and docs/SERVER_SETUP.md to reflect current structure
---
## NEW FEATURES
### 1 — License page (public)
Create a public-facing `/licence.php` page, styled consistently with `apropos.php`.
- [ ] **`public/licence.php`** — new public page; fetches content from `pages` table
(slug `'licenses'`); renders with `nl2br` / Markdown; uses `apropos.css` layout
or a new `licence.css` if divergent styling needed
- [ ] **`templates/nav.php`** — add "Licence" link between "Répertoire" and "À Propos"
(or after "À Propos"); apply `site-nav__link--active` when `$currentNav === 'licence'`
- [ ] The `pages` table already has an `INSERT OR IGNORE` seed for slug `'licenses'`
in `storage/schema.sql` — no schema change needed here; verify the row exists
in the live DB and add a migration if not
---
### 2 — Admin: WYSIWYG/Markdown editors for static pages
Allow admins to edit the content of the "À propos" and "Licence" pages from the admin
panel, stored in the existing `pages` table.
#### 2a — `src/Database.php`
- [ ] `getPage(string $slug): array|null``SELECT * FROM pages WHERE slug = ?`
- [ ] `savePage(string $slug, string $content): void` — `UPDATE pages SET content = ?,
updated_at = CURRENT_TIMESTAMP WHERE slug = ?`; throw if slug not found
#### 2b — Admin pages editor UI
- [ ] **`public/admin/pages.php`** — list all editable pages (fetch all from `pages`
table); links to edit each one; reuse `.admin-table` styles
- [ ] **`public/admin/pages-edit.php`** — edit form for a single page (slug passed via
GET `?slug=`); loads page content; renders a **EasyMDE** (or SimpleMDE) Markdown
WYSIWYG editor via CDN; POST action → `actions/page.php`
- Include EasyMDE from CDN: `https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js`
+ matching CSS
- Textarea `name="content"` pre-filled with `$page['content']`; hidden input
`name="slug"` with the page slug
- CSRF token as hidden input
#### 2c — `public/admin/actions/page.php`
- [ ] Auth guard + CSRF check
- [ ] Validate `slug` ∈ `['about', 'licenses', 'charte', 'contact']`
- [ ] Validate `content` length ≤ 65 535 chars (TEXT column limit)
- [ ] Call `$db->savePage($slug, $content)`
- [ ] Set `$_SESSION['success']`; redirect to `../pages.php`
#### 2d — Public pages render Markdown
- [ ] **`public/apropos.php`** — replace hardcoded HTML body text with content from
`$db->getPage('about')`; render Markdown via a PHP parser
(use **Parsedown** via Composer, or a ~150-line zero-dependency inline parser
if Composer is not available in this project — check `composer.json`)
- [ ] **`public/licence.php`** — same: render `$db->getPage('licenses')` as Markdown
- [ ] Choose Markdown renderer: check if Composer is available; if not, bundle
`vendor/Parsedown.php` as a single-file include (MIT licensed, copy-paste friendly)
#### 2e — Nav links in admin
- [ ] **`templates/admin/head.php`** — add "Pages statiques" nav item linking to
`/admin/pages.php`; apply active class when on `pages.php` or `pages-edit.php`
---
### 3 — License field on TFE forms
Add a "Licence" dropdown to the Add and Edit TFE forms. The `license_types` table
already exists in the schema with an `id`, `name`, `description` structure but has
no seed data yet.
#### 3a — Schema / DB
- [ ] **`storage/schema.sql`** — add seed `INSERT OR IGNORE` for common Creative Commons
licences into `license_types`:
`CC BY 4.0`, `CC BY-SA 4.0`, `CC BY-ND 4.0`, `CC BY-NC 4.0`,
`CC BY-NC-SA 4.0`, `CC BY-NC-ND 4.0`, `Tous droits réservés`, `Domaine public`
- [ ] **`storage/migrations/003_seed_license_types.sql`** — same inserts wrapped in
`INSERT OR IGNORE` so they're safe to run on an existing DB; also contains
no structural changes (no ALTER TABLE needed — `theses.license_id` FK already
exists in the schema)
- [ ] Verify live DB has `license_types` table; if missing (older DB without that
table), add `CREATE TABLE IF NOT EXISTS` to the migration
#### 3b — `src/Database.php`
- [ ] `getLicenseTypes(): array` — `SELECT * FROM license_types ORDER BY name`
- [ ] `getAllLicenseTypes(): array` — alias for form-loading consistency
#### 3c — Add form (`public/admin/add.php`)
- [ ] Load `$licenseTypes = $db->getAllLicenseTypes()` alongside existing reference data
- [ ] Add "Licence" `<select name="license_id">` row in the form (between synopsis
and duration, or after duration — whichever is logical); include empty/unknown
option as default
#### 3d — Add action (`public/admin/actions/formulaire.php`)
- [ ] Read `$licenseId = filter_var($_POST['license_id'] ?? '', FILTER_VALIDATE_INT) ?: null`
- [ ] Add `license_id` to the `INSERT INTO theses (…)` column list and `$stmt->execute([…])`
#### 3e — Edit form (`public/admin/edit.php`)
- [ ] Load `$licenseTypes = $db->getAllLicenseTypes()`
- [ ] Add "Licence" `<select name="license_id">` row; pre-select current `$thesis['license_id']`
(note: view exposes `license_type` as the name string; need to separately query
`theses.license_id` for the raw FK value, or add it to the view — see 3f)
- [ ] In the POST handler: `UPDATE theses SET … license_id = ? …` with the submitted value
#### 3f — View update
- [ ] **`storage/schema.sql`** — update `v_theses_full` view to also `SELECT t.license_id`
(the raw FK) alongside the existing `lt.name as license_type`; required so the
edit form can pre-select the correct `<option>`
- [ ] **`storage/migrations/003_seed_license_types.sql`** — include `DROP VIEW IF EXISTS
v_theses_full; CREATE VIEW … (updated definition)`
#### 3g — TFE public page
- [ ] **`public/tfe.php`** — add "Licence :" meta row using `$data['license_type']`
(already in the view); display only if non-null
---
### 4 — Jury composition section in Add/Edit forms
Replace the current flat "promoteur interne / externe" fields with a structured
**Composition du jury** section: président·e, promoteur·ice, lecteur·ices (mixed
internal/external).
#### 4a — Schema / DB
Current state: `supervisors` table + `thesis_supervisors` junction with a bare
`supervisor_order` integer. No role distinction.
- [ ] **`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`
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`
- 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)`
#### 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)
#### 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)`
#### 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()`
#### 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
---
### 5 — Banner image upload for home page cards
Each TFE can have an optional banner image used as its home page card thumbnail.
This is distinct from the existing "couverture" file concept (which goes into
`thesis_files` as type `'cover'`) — the banner is specifically optimised for the
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`
#### 5b — Add form (`public/admin/add.php`)
- [ ] Add "Image bannière (page d'accueil)" file input row:
```html
<input type="file" name="banner" accept="image/jpeg,image/png,image/webp">
```
- [ ] Hint: "JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB."
- [ ] Placed after the existing "Image de 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)
#### 5d — Edit form (`public/admin/edit.php`)
- [ ] Display existing banner as `<img>` 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`
#### 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
---
### 6 — Home page: gradient placeholder cards & random ordering
#### 6a — Gradient placeholder for cards without a banner
When a TFE has no banner and no cover image, display a CSS gradient using HSL hue
derived from the thesis ID (deterministic per TFE, consistent across page loads).
- [ ] **`public/index.php`** — in the card thumbnail block, replace the current
`<div class="card__media--placeholder">◻</div>` with:
```php
// Compute a deterministic hue from thesis ID (160° spread)
$hue = ($item['id'] * 47 + 160) % 360; // 47 is a prime step, 160° base
$hue2 = ($hue + 40) % 360; // second stop
?>
<div class="card__media--gradient"
style="background: linear-gradient(135deg,
hsl(<?= $hue ?>, 60%, 65%),
hsl(<?= $hue2 ?>, 55%, 45%));">
<span class="card__gradient-author"><?= htmlspecialchars($item['authors'] ?? '') ?></span>
<span class="card__gradient-title"><?= htmlspecialchars($item['title']) ?></span>
</div>
```
- [ ] **`public/assets/main.css`** — add styles for `.card__media--gradient`,
`.card__gradient-author`, `.card__gradient-title`:
- `.card__media--gradient`: `width:100%; height:100%; display:flex; flex-direction:column;
align-items:center; justify-content:center; padding:1rem; text-align:center;`
- `.card__gradient-author`: `color:#fff; font-size:0.75rem; opacity:.85; margin-bottom:.25rem;`
- `.card__gradient-title`: `color:#fff; font-size:0.85rem; font-weight:600;
display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden;`
#### 6b — Banner image as card thumbnail
- [ ] **`public/index.php`** — update thumbnail resolution logic:
1. Check `$item['banner_path']` first (new banner field)
2. Fall back to first image in `$item['files']` (existing logic)
3. Fall back to `$item['cover_image']` if present
4. Fall through to gradient placeholder (6a)
- [ ] Note: `getPublishedTheses()` currently returns from `v_theses_public` which does
not include `files` (they're loaded lazily in `getThesisById`); the index loop
currently accesses `$item['files']` but the method doesn't return them — this
is a pre-existing bug; either add banner_path to the view (simpler) or fix the
files join; the banner column on the view (5a) is sufficient for 6a/6b
#### 6c — Random ordering from the latest year
- [ ] **`src/Database.php`** — add `getLatestYearTheses(int $limit = 24): array`:
```sql
SELECT * FROM v_theses_public
WHERE year = (SELECT MAX(year) FROM theses WHERE is_published = 1)
ORDER BY RANDOM()
LIMIT :limit
```
Note: `ORDER BY RANDOM()` is re-evaluated on every call — no caching needed;
the randomness is per PHP request (page load)
- [ ] **`public/index.php`** — when no `?year` filter is active and `?page=1` (or no page):
- Replace `$db->getPublishedTheses($itemsPerPage, $offset)` with
`$db->getLatestYearTheses($itemsPerPage)`
- Still show paginated view for `?year=X` filter and `?page=N` requests
- Add a visual indicator (e.g. `<p class="home-latest-label">Découvrez les TFE de …</p>`)
showing which year is being displayed on the random home view
- Pagination is disabled/hidden on the default home view (random selection from
one year — no pages needed unless the year has >24 TFEs; handle gracefully)
---
## Refactor: M2M tags via `tags` + `thesis_tags` junction table ## Refactor: M2M tags via `tags` + `thesis_tags` junction table
The current schema stores keywords in a `keywords` table joined via `thesis_keywords`. The current schema stores keywords in a `keywords` table joined via `thesis_keywords`.
@@ -108,214 +452,60 @@ results).
#### 5a — `src/Database.php` — new tag-management methods #### 5a — `src/Database.php` — new tag-management methods
- [ ] `getAllTagsWithCount(): array` — return all tags with a `thesis_count` column: - [ ] `getAllTagsWithCount(): array` — return all tags with a `thesis_count` column
```sql - [ ] `renameTag(int $id, string $newName): void`
SELECT t.id, t.name, - [ ] `mergeTag(int $sourceId, int $targetId): void`
COUNT(tt.thesis_id) AS thesis_count - [ ] `deleteTag(int $id): void`
FROM tags t
LEFT JOIN thesis_tags tt ON t.id = tt.tag_id
GROUP BY t.id ORDER BY t.name
```
- [ ] `renameTag(int $id, string $newName): void` — UPDATE `tags SET name = ? WHERE id = ?`;
unique-constraint violation must be caught and re-thrown as a user-readable
`InvalidArgumentException`
- [ ] `mergeTag(int $sourceId, int $targetId): void` — within a transaction:
1. INSERT OR IGNORE into `thesis_tags(tag_id, thesis_id)` for every row in
`thesis_tags WHERE tag_id = $sourceId` (re-point to target, skip dupes)
2. DELETE FROM `thesis_tags WHERE tag_id = $sourceId`
3. DELETE FROM `tags WHERE id = $sourceId`
- [ ] `deleteTag(int $id): void` — within a transaction:
DELETE FROM `thesis_tags WHERE tag_id = ?` first (FK cascade may handle this
after the schema migration, but an explicit delete is safer), then
DELETE FROM `tags WHERE id = ?`; reject with exception if `thesis_count > 0`
and the caller did not pass `$force = true`
#### 5b — `public/admin/tags.php` — list + inline-edit view #### 5b — `public/admin/tags.php` — list + inline-edit view
- [ ] Auth guard: `AdminAuth::requireLogin()` at top; CSRF token in session - [ ] Auth guard, CSRF, table with rename/merge/delete per row
- [ ] Page title: `"Gestion des mots-clés"` (reuses `$pageTitle` for `head.php`)
- [ ] Load `getAllTagsWithCount()` for display
- [ ] Table columns: **Mot-clé** (editable inline) · **Nb de TFE** · **Actions**
- [ ] Each row has:
- An inline `<form>` (POST to `actions/tag.php`, action=`rename`) pre-filled with
the current tag name, so the admin can edit in-place and submit per row
- A **Fusionner →** button that reveals a `<select>` of other tags and a confirm
submit (POST action=`merge`, fields: `source_id`, `target_id`)
- A **Supprimer** button — disabled / greyed if `thesis_count > 0`; otherwise
submits POST action=`delete`, field: `tag_id`; requires JS `confirm()` before
submit
- [ ] Flash success/error messages from `$_SESSION['success']` / `$_SESSION['error']`
(same pattern as `index.php`)
- [ ] Empty state: "Aucun mot-clé enregistré." if table is empty
- [ ] Stats bar (reuse `.admin-stats` CSS): total tag count, total TFE with ≥1 tag,
total tags with 0 TFE (orphan count)
#### 5c — `public/admin/actions/tag.php` — POST action handler #### 5c — `public/admin/actions/tag.php` — POST action handler
- [ ] Auth guard + CSRF check (same pattern as `publish.php`) - [ ] Route on `$_POST['action']`: rename, merge, delete
- [ ] Route on `$_POST['action']`:
- `'rename'`: validate `tag_id` (int > 0) and `name` (non-empty, max 100 chars,
trimmed); call `renameTag()`; set `$_SESSION['success']`
- `'merge'`: validate `source_id` and `target_id` are distinct positive ints; call
`mergeTag()`; set success message including both tag names
- `'delete'`: validate `tag_id`; call `deleteTag()`; set success message
- [ ] On any exception: set `$_SESSION['error']`
- [ ] Regenerate CSRF token after every action
- [ ] Redirect back to `/admin/tags.php` in all cases
#### 5d — Nav & routing #### 5d — Nav & routing
- [ ] `templates/admin/head.php`: add nav link - [ ] `templates/admin/head.php`: add nav link to `/admin/tags.php`
`<a href="/admin/tags.php" …>Mots-clés</a>` between "Ajouter un TFE" and
"Importer une liste de TFE"; apply `active` class on `tags.php`
- [ ] `nginx/posterg.conf` (or equivalent): if using try_files / location blocks,
confirm `/admin/tags.php` and `/admin/actions/tag.php` are reachable (likely
already covered by the existing `*.php` rule, but verify)
#### 5e — Propagation safety checklist #### 5e — Propagation safety checklist
These must remain unbroken after the tag-management UI is live: - [ ] Verify all search/display paths remain correct after tag ops
- [ ] Renaming a tag updates `tags.name`; `v_theses_full` reads from `tags` directly,
so `GROUP_CONCAT` output updates automatically — no view rebuild needed
- [ ] Renaming a tag does **not** affect `thesis_tags` rows (only the name column
changes); search results remain intact
- [ ] Merging tag A→B: all theses that had tag A now appear under tag B; the
`EXISTS` subquery in `buildSearchConditions` finds them via `thesis_tags`
- [ ] Deleting a tag with `thesis_count > 0` is blocked by default (explicit
`$force` parameter required); the UI never shows a delete button for in-use
tags — double protection
- [ ] `edit.php` keyword field renders the current CSV of tag names from
`$thesis['keywords']` (view column); after a rename the edit page will show
the new name on next load — no extra work needed
- [ ] The public `search.php` keyword filter (`$_GET['tag']`) uses tag names; after a
rename, any bookmarked URL with the old name will return 0 results (expected
behaviour — no redirect needed, but note it in a code comment)
### 6 — Tests ### 6 — Tests
- [ ] `tests/Unit/DatabaseTest.php`: add test for `findOrCreateTag` round-trip - [ ] `tests/Unit/DatabaseTest.php`: add test for `findOrCreateTag` round-trip
- [ ] `tests/Integration/SearchTest.php`: add test for tag-filter search using the new - [ ] `tests/Integration/SearchTest.php`: add test for tag-filter search using the new subquery
subquery path (not the LIKE-on-GROUP_CONCAT path)
### 6 — Fixtures / seed data ### 6 — Fixtures / seed data
- [ ] `storage/fixtures/CreateTestDatabase.php`: update to use `tags` / `thesis_tags` - [ ] `storage/fixtures/CreateTestDatabase.php`: update to use `tags` / `thesis_tags`
table names and `findOrCreateTag()` table names and `findOrCreateTag()`
---
## Feature: Mode Maintenance ## Feature: Mode Maintenance
Adds "Déployer" / "Maintenance" buttons in admin. When maintenance mode is on, - [ ] Storage flag file `storage/maintenance.flag`
the public site is unavailable (503) while the admin section keeps working. - [ ] 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`
### Implementation ---
- [ ] **Storage** — store maintenance flag as a file on disk (`storage/maintenance.flag`)
so it requires no DB and survives restarts; flag presence = maintenance mode ON
- [ ] **Public gate** — add a check at the top of every public-facing PHP entry-point
(`public/index.php`, `public/search.php`, `public/tfe.php`, `public/media.php`,
`public/apropos.php`) via a shared helper in `config/bootstrap.php`:
`checkMaintenanceMode()` — if flag exists, render a 503 maintenance page and exit
- [ ] **Maintenance page** — create `public/maintenance.php` (503 HTML page, styled
minimally, not imported via bootstrap so it never recurses)
- [ ] **Admin action** — create `public/admin/actions/maintenance.php` (POST handler):
CSRF-validated, two actions: `enable` (touch flag file) and `disable` (unlink flag)
- [ ] **Admin UI** — add a "Site en ligne / Maintenance" status bar in
`public/admin/index.php` with "Déployer" (→ disable) and "Maintenance" (→ enable)
buttons; display current state clearly (green = en ligne, orange = maintenance)
- [ ] **Admin exemption** — admin pages (`/admin/*`) must NOT be gated; the maintenance
check must only run on public routes; admin bootstrap already bypasses it by
not calling `checkMaintenanceMode()`
- [ ] **`src/config.php`** — add `MAINTENANCE_FLAG_PATH` constant pointing to
`storage/maintenance.flag`
## Feature: TFE Visibility States (publique / interne / interdit) ## Feature: TFE Visibility States (publique / interne / interdit)
Replaces the binary `is_published` with a three-state `visibility` field on each TFE, - [ ] DB migration `002_add_visibility.sql`
controlling what the public site exposes. Default: `interne`. - [ ] `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`
### States ---
| State | DB value | Public page: metadata | Public page: files | Note displayed |
|------------|----------|-----------------------|--------------------|----------------|
| `publique` | `public` | ✓ full | ✓ via media.php | — |
| `interne` | `intern` | ✓ full | ✗ hidden | "Copies physiques disponibles aux archives de l'école." |
| `interdit` | `forbid` | ✓ full | ✗ hidden | `context_note` from DB (jury note, editable in admin) |
### Schema
- [ ] **DB migration** — `storage/migrations/002_add_visibility.sql`:
- `ALTER TABLE theses ADD COLUMN visibility TEXT NOT NULL DEFAULT 'intern'`
- `UPDATE theses SET visibility = 'public' WHERE is_published = 1` (migrate existing data)
- update `v_theses_full` view to expose `visibility` (DROP + CREATE in migration)
- update `v_theses_public` view: change filter from `is_published = 1` to
`is_published = 1` (keep; `is_published` stays for "show in listings" gate)
- **Note**: `is_published` keeps its meaning (appears in public listings at all);
`visibility` controls *what* is accessible once found; a TFE can be
`is_published = 1` + `visibility = 'intern'` → appears in list, files hidden
- [ ] **`storage/schema.sql`** — add `visibility` column + update views + add
`idx_theses_visibility` index
- [ ] **`storage/fixtures/CreateTestDatabase.php`** — seed some TFEs with each visibility
state (default: `intern`)
### Backend
- [ ] **`src/Database.php`**:
- `getThesisById($id)` — already used by `tfe.php`; ensure it returns `visibility`
and `context_note`
- `getThesis($id)` (admin) — already returns all columns; ensure `visibility` and
`context_note` pass through
- `getThesesList()` — add `visibility` to SELECT for admin list display
- `setVisibility(int $thesisId, string $state): void` — UPDATE
`theses SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`;
validate `$state` ∈ `['public', 'intern', 'forbid']`
- `bulkSetVisibility(array $ids, string $state): void` — batch UPDATE for the
bulk-action bar
### Public site
- [ ] **`public/media.php`** — add visibility check:
1. Load `thesis_files` row by `path` to get `thesis_id`
2. Load `theses.visibility` for that `thesis_id` (one extra query)
3. If `visibility !== 'public'` → return 403
(This is the critical security gate; all other changes are UX)
- [ ] **`public/tfe.php`** — conditional rendering based on `$data['visibility']`:
- `publique`: render files normally (existing logic)
- `interne`: skip files block; show notice "Les copies physiques de ce travail
sont disponibles dans les archives de l'école."
- `interdit`: skip files block; show `$data['context_note']` (jury note) if set,
otherwise a generic access-restricted message
- [ ] **`public/index.php`** / **`public/search.php`** — no change needed (thumbnail
logic: if `visibility !== 'public'`, skip thumbnail even if file exists; adjust
the thumbnail-lookup block in index.php)
### Admin UI
- [ ] **`public/admin/edit.php`**:
- Replace the current `is_published` checkbox with a `<select name="visibility">`
with three labelled options (Publique / Interne / Interdit)
- Keep `is_published` checkbox separately (controls listing visibility)
- Add `<textarea name="context_note">` for jury note (shown only when
`visibility = interdit`, via JS toggle, but always submitted so admin can
pre-fill before switching state)
- Update the POST handler to save `visibility` and `context_note` via
`UPDATE theses SET visibility = ?, context_note = ? …`
- [ ] **`public/admin/index.php`**:
- Update status badge column: show three-state badge instead of binary
"Publié / En attente":
- `publique` → green "Publique"
- `interne` → blue "Interne"
- `interdit` → red "Interdit"
- Update stats bar: counts for each state instead of published/pending
- Add bulk-action buttons for visibility: "Rendre publique", "Rendre interne",
"Rendre interdit" (replaces or supplements existing publish/unpublish)
- Update JS `bulkAction()` to support new action names
- [ ] **`public/admin/actions/publish.php`** — extend to handle new visibility actions:
`set_public`, `set_intern`, `set_forbid` for single and bulk modes;
keep existing `publish`/`unpublish` actions for `is_published` toggle;
alternatively rename/create `public/admin/actions/visibility.php` as a
dedicated handler and keep `publish.php` for `is_published` only
- [ ] **`public/admin/thanks.php`** — add "Visibilité" row to the info display
## Pending ## Pending