mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
docs: add feature tasks for licence page, admin WYSIWYG, jury section, banner upload, and home randomisation
This commit is contained in:
548
TODO.md
548
TODO.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user