# 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" ` ``` - [ ] 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 `` 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 (`` → 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)