Files
xamxam/TODO.md

25 KiB

TODO

Styling Redesign (matching design images)

  • Redesign shared nav bar (purple gradient top, flat, POSTERG / RÉPERTOIRE / À PROPOS)
  • Redesign shared search bar (full-width, icon, bottom border only, white bg)
  • Rewrite common.css (nav + search bar components)
  • Rewrite main.css (home page — white bg, media card grid, label below)
  • Rewrite search.css (répertoire index — 4-col ANNÉES/CATÉGORIES/ÉTUDIANTES/MOTS-CLÉS)
  • Rewrite tfe.css (TFE page — 2-col, large author/title left, media right)
  • Add apropos.css (À Propos — 2-col, large monospace text)
  • Rewrite admin.css (dark bg, purple gradient nav, bottom-border-only form inputs)
  • Update templates/nav.php (new shared nav partial)
  • Update templates/search-bar.php (new shared search bar partial)
  • Rewrite public/index.php (home page with new layout)
  • Rewrite public/search.php (répertoire index view + search results view)
  • Rewrite public/tfe.php (individual TFE page)
  • Create public/apropos.php (À Propos page)
  • Rewrite templates/admin/head.php (admin nav)
  • Rewrite templates/admin/footer.php (clean close)
  • Rewrite public/admin/add.php (form with row layout)
  • Rewrite public/admin/index.php (dark table)
  • Rewrite public/admin/edit.php (form with row layout)
  • Rewrite public/admin/login.php (centered dark login box)
  • Rewrite public/admin/thanks.php (dark info cards)
  • Rewrite public/admin/import.php (clean dark form)

Justfile / Ops

  • Simplify serve and deploy to one recipe each
  • Remove sysadmin recipes (server-logs, server-status, deploy-nginx, deploy-admin-tools)
  • Extract server scripts to scripts/ (deploy-server.sh, manage-admin-users.sh)
  • Guard deploy-db against overwriting existing remote database
  • 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|nullSELECT * FROM pages WHERE slug = ?
  • savePage(string $slug, string $content): voidUPDATE 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)
  • 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(): arraySELECT * 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

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 keywordstags; rename column keywords.keywordtags.name
  • Rename junction table thesis_keywordsthesis_tags; rename FK column thesis_keywords.keyword_idthesis_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 findOrCreateKeywordfindOrCreateTag and getUsedKeywordsgetUsedTags (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_keywordsDELETE FROM thesis_tags, insert loop)

4 — Public read paths

  • public/search.php: rename $keywords$tags; update getUsedKeywords() call → getUsedTags(); rename GET param keywordtag (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.phpsetVisibility(), 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

  • Add flake.nix for Nix-based PHP dev environment
  • 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

  • Create scripts/setup-server.sh (one-time server setup: group, ownership, setgid 2775 on dirs)
  • Add just setup-server recipe (rsync + run setup-server.sh on remote)
  • Exclude .claude and .pi from rsync deploy
  • 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)