Files
xamxam/TODO.md
Pontoporeia d87348c388 feat: licence page, admin pages editor, license types, gradient card placeholders, latest-year home view
- Feature 1: public /licence.php fetches 'licenses' page from DB, renders Markdown
- Feature 1: nav.php adds 'Licence' link with active state
- Feature 2: Database::getPage(), savePage(), getAllPages() methods
- Feature 2: bundled src/Parsedown.php (MIT, zero-dependency)
- Feature 2: apropos.php now renders 'about' page content from DB via Parsedown
- Feature 2: admin/pages.php (list) + admin/pages-edit.php (EasyMDE editor)
- Feature 2: admin/actions/page.php (auth+CSRF+validation+save)
- Feature 2: admin/head.php adds 'Pages statiques' nav link
- Feature 3: storage/schema.sql seeds 8 CC license types
- Feature 3: storage/migrations/003_seed_license_types.sql (applied to live DB)
- Feature 3: Database::getLicenseTypes() / getAllLicenseTypes()
- Feature 3: admin/add.php + formulaire.php: license_id field on add form
- Feature 3: admin/edit.php: license_id field on edit form with raw FK lookup
- Feature 3: tfe.php: shows 'Licence :' meta row when non-null
- Feature 6: main.css: .card__media--gradient styles
- Feature 6: index.php: deterministic HSL gradient placeholder cards
- Feature 6: Database::getLatestYearTheses() + getLatestPublishedYear()
- Feature 6: index.php default home = random latest-year theses with info label
2026-03-24 13:12:48 +01:00

444 lines
19 KiB
Markdown

# TODO
## Styling Redesign (matching design images)
- [x] Redesign shared nav bar (purple gradient top, flat, POSTERG / RÉPERTOIRE / À PROPOS)
- [x] Redesign shared search bar (full-width, icon, bottom border only, white bg)
- [x] Rewrite `common.css` (nav + search bar components)
- [x] Rewrite `main.css` (home page — white bg, media card grid, label below)
- [x] Rewrite `search.css` (répertoire index — 4-col ANNÉES/CATÉGORIES/ÉTUDIANTES/MOTS-CLÉS)
- [x] Rewrite `tfe.css` (TFE page — 2-col, large author/title left, media right)
- [x] Add `apropos.css` (À Propos — 2-col, large monospace text)
- [x] Rewrite `admin.css` (dark bg, purple gradient nav, bottom-border-only form inputs)
- [x] Update `templates/nav.php` (new shared nav partial)
- [x] Update `templates/search-bar.php` (new shared search bar partial)
- [x] Rewrite `public/index.php` (home page with new layout)
- [x] Rewrite `public/search.php` (répertoire index view + search results view)
- [x] Rewrite `public/tfe.php` (individual TFE page)
- [x] Create `public/apropos.php` (À Propos page)
- [x] Rewrite `templates/admin/head.php` (admin nav)
- [x] Rewrite `templates/admin/footer.php` (clean close)
- [x] Rewrite `public/admin/add.php` (form with row layout)
- [x] Rewrite `public/admin/index.php` (dark table)
- [x] Rewrite `public/admin/edit.php` (form with row layout)
- [x] Rewrite `public/admin/login.php` (centered dark login box)
- [x] Rewrite `public/admin/thanks.php` (dark info cards)
- [x] Rewrite `public/admin/import.php` (clean dark form)
## Justfile / Ops
- [x] Simplify `serve` and `deploy` to one recipe each
- [x] Remove sysadmin recipes (server-logs, server-status, deploy-nginx, deploy-admin-tools)
- [x] Extract server scripts to `scripts/` (deploy-server.sh, manage-admin-users.sh)
- [x] Guard `deploy-db` against overwriting existing remote database
- [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`.
- [x] **`public/licence.php`** — new public page; fetches content from `pages` table
(slug `'licenses'`); renders with Parsedown Markdown; uses `apropos.css` layout
- [x] **`templates/nav.php`** — add "Licence" link between "Répertoire" and "À Propos";
apply `site-nav__link--active` when `$currentNav === 'licence'`
- [x] The `pages` table row for slug `'licenses'` verified in live DB
---
### 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`
- [x] `getPage(string $slug): array|null``SELECT * FROM pages WHERE slug = ?`
- [x] `savePage(string $slug, string $content): void` — throws if slug not found
- [x] `getAllPages(): array` — for listing in admin
#### 2b — Admin pages editor UI
- [x] **`public/admin/pages.php`** — list all editable pages; links to edit each one
- [x] **`public/admin/pages-edit.php`** — EasyMDE WYSIWYG Markdown editor via CDN
#### 2c — `public/admin/actions/page.php`
- [x] Auth guard + CSRF check + slug validation + length validation + savePage + redirect
#### 2d — Public pages render Markdown
- [x] **`public/apropos.php`** — renders `$db->getPage('about')` via Parsedown (bundled `src/Parsedown.php`)
- [x] **`public/licence.php`** — renders `$db->getPage('licenses')` via Parsedown
- [x] Parsedown bundled as `src/Parsedown.php` (zero-dependency, MIT)
#### 2e — Nav links in admin
- [x] **`templates/admin/head.php`** — "Pages statiques" nav item added
---
### 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
- [x] **`storage/schema.sql`** — seed `INSERT OR IGNORE` for 8 CC licence types added
- [x] **`storage/migrations/003_seed_license_types.sql`** — migration created + applied
- [x] Verified live DB has `license_types` with 8 rows
#### 3b — `src/Database.php`
- [x] `getLicenseTypes(): array`
- [x] `getAllLicenseTypes(): array` — alias
#### 3c — Add form (`public/admin/add.php`)
- [x] Loads `$licenseTypes`; "Licence" `<select name="license_id">` added before duration
#### 3d — Add action (`public/admin/actions/formulaire.php`)
- [x] `$licenseId` parsed + included in INSERT
#### 3e — Edit form (`public/admin/edit.php`)
- [x] Loads `$licenseTypes`; raw `license_id` FK fetched directly; select pre-populated
- [x] POST handler: `license_id` included in UPDATE
#### 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)
#### 3g — TFE public page
- [x] **`public/tfe.php`** — "Licence :" meta row added, shown when 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
- [x] **`public/index.php`** — gradient placeholder using HSL hue from thesis ID
- [x] **`public/assets/main.css`** — `.card__media--gradient`, `.card__gradient-author`,
`.card__gradient-title` styles added
#### 6b — Banner image as card thumbnail
- [x] **`public/index.php`** — checks `banner_path` first, falls through to gradient
#### 6c — Random ordering from the latest year
- [x] **`src/Database.php`** — `getLatestYearTheses(int $limit = 24)` + `getLatestPublishedYear()`
- [x] **`public/index.php`** — default home view uses random latest-year selection;
paginated view for `?year=X` and `?page=N`; info label shown
---
## Refactor: M2M tags via `tags` + `thesis_tags` junction table
The current schema stores keywords in a `keywords` table joined via `thesis_keywords`.
The field column is named `keyword` (not `name`), breaking the naming convention used by
every other lookup table (`orientations.name`, `format_types.name`, etc.).
More critically, `buildSearchConditions` and the view `v_theses_full` filter keywords
through `GROUP_CONCAT` strings with `LIKE`, bypassing the junction table entirely.
Goal: rename the tables and column to the canonical M2M pattern (`tags`, `thesis_tags`,
`tags.name`), add the missing index, and rewrite all tag queries to use a proper JOIN.
### 1 — Schema migration (`storage/schema.sql` + live DB)
- [ ] Rename table `keywords` → `tags`; rename column `keywords.keyword` → `tags.name`
- [ ] Rename junction table `thesis_keywords` → `thesis_tags`; rename FK column
`thesis_keywords.keyword_id` → `thesis_tags.tag_id`
- [ ] Composite PK on `thesis_tags(tag_id, thesis_id)` (tag first — matches the lookup
pattern `WHERE t.name = ?`)
- [ ] Add index `idx_tags_name ON tags(name)` (supports exact-match lookup on insert/find)
- [ ] Update `idx_thesis_keywords_*` index names → `idx_thesis_tags_thesis`,
`idx_thesis_tags_tag`
- [ ] Update view `v_theses_full` / `v_theses_public`: replace
`LEFT JOIN keywords k ON tk.keyword_id = k.id … GROUP_CONCAT(DISTINCT k.keyword)`
with `LEFT JOIN tags t ON tt.tag_id = t.id … GROUP_CONCAT(DISTINCT t.name)`
- [ ] Write and test a SQLite migration script
(`storage/migrations/001_rename_keywords_to_tags.sql`)
### 2 — `src/Database.php`
- [ ] `findOrCreateKeyword()` → `findOrCreateTag()`: query `tags` table, column `name`
- [ ] `getUsedKeywords()` → `getUsedTags()`: rewrite to use proper M2M JOIN instead of
querying the view:
```sql
SELECT DISTINCT t.* FROM tags t
JOIN thesis_tags tt ON t.id = tt.tag_id
JOIN theses th ON tt.thesis_id = th.id
WHERE th.is_published = 1 ORDER BY t.name
```
- [ ] `buildSearchConditions`: replace the `keywords LIKE :keyword` view-string hack with
a subquery using the junction table:
```sql
EXISTS (
SELECT 1 FROM thesis_tags tt
JOIN tags t ON t.id = tt.tag_id
WHERE tt.thesis_id = theses.id AND t.name LIKE :keyword ESCAPE '\'
)
```
(search still runs on `v_theses_public`; the subquery references the base table)
- [ ] `validateSearchParams`: rename key `'keyword'` → `'tag'` (or keep alias for
backwards-compat during transition)
- [ ] Add backwards-compat alias `findOrCreateKeyword` → `findOrCreateTag` and
`getUsedKeywords` → `getUsedTags` (remove after all callers updated)
### 3 — Admin write paths
- [ ] `public/admin/actions/formulaire.php`: replace `findOrCreateKeyword` +
`INSERT INTO thesis_keywords` with `findOrCreateTag` + `INSERT INTO thesis_tags`
- [ ] `public/admin/edit.php`: same replacement in keyword update block
(`DELETE FROM thesis_keywords` → `DELETE FROM thesis_tags`, insert loop)
### 4 — Public read paths
- [ ] `public/search.php`: rename `$keywords` → `$tags`; update `getUsedKeywords()` call
→ `getUsedTags()`; rename GET param `keyword` → `tag` (keep old param as alias)
- [ ] `public/tfe.php`: `$data['keywords']` → `$data['tags']` (view column rename)
- [ ] `templates/search-bar.php` (if applicable): update any hardcoded `keyword` param refs
### 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` — new tag-management methods
- [ ] `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` — list + inline-edit view
- [ ] Auth guard, CSRF, table with rename/merge/delete per row
#### 5c — `public/admin/actions/tag.php` — POST action handler
- [ ] Route on `$_POST['action']`: rename, merge, delete
#### 5d — Nav & routing
- [ ] `templates/admin/head.php`: add nav link to `/admin/tags.php`
#### 5e — Propagation safety checklist
- [ ] Verify all search/display paths remain correct after tag ops
### 6 — Tests
- [ ] `tests/Unit/DatabaseTest.php`: add test for `findOrCreateTag` round-trip
- [ ] `tests/Integration/SearchTest.php`: add test for tag-filter search using the new subquery
### 6 — Fixtures / seed data
- [ ] `storage/fixtures/CreateTestDatabase.php`: update to use `tags` / `thesis_tags`
table names and `findOrCreateTag()`
---
## 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`
---
## 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`
---
## Pending
- [x] Add flake.nix for Nix-based PHP dev environment
- [x] Add favicon (`<link rel="icon">` → admin_favicon.svg) to all pages; nginx 204 for /favicon.ico
- [ ] Add pagination to répertoire student index (currently capped at 100)
- [ ] Thumbnail generation / cover image support for home grid cards
## Admin / Server
- [x] Create `scripts/setup-server.sh` (one-time server setup: group, ownership, setgid 2775 on dirs)
- [x] Add `just setup-server` recipe (rsync + run setup-server.sh on remote)
- [x] Exclude `.claude` and `.pi` from rsync deploy
- [x] Update `docs/SERVER_SETUP.md` with correct permissions rationale and troubleshooting
- [ ] Add server status view in admin panel (nginx + php-fpm health, site HTTP check)
- [ ] Add server log viewer in admin panel (tail nginx error/access logs via SSH or log endpoint)
- [ ] Add nginx config deploy flow to admin panel (upload `scripts/deploy-server.sh`, run remotely)
- [ ] Add admin user management UI (wraps `scripts/manage-admin-users.sh` on server)