- 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
19 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
serveanddeployto 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-dbagainst 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 frompagestable (slug'licenses'); renders with Parsedown Markdown; usesapropos.csslayouttemplates/nav.php— add "Licence" link between "Répertoire" and "À Propos"; applysite-nav__link--activewhen$currentNav === 'licence'- The
pagestable 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
getPage(string $slug): array|null—SELECT * FROM pages WHERE slug = ?savePage(string $slug, string $content): void— throws if slug not foundgetAllPages(): array— for listing in admin
2b — Admin pages editor UI
public/admin/pages.php— list all editable pages; links to edit each onepublic/admin/pages-edit.php— EasyMDE WYSIWYG Markdown editor via CDN
2c — public/admin/actions/page.php
- Auth guard + CSRF check + slug validation + length validation + savePage + redirect
2d — Public pages render Markdown
public/apropos.php— renders$db->getPage('about')via Parsedown (bundledsrc/Parsedown.php)public/licence.php— renders$db->getPage('licenses')via Parsedown- Parsedown bundled as
src/Parsedown.php(zero-dependency, MIT)
2e — Nav links in admin
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
storage/schema.sql— seedINSERT OR IGNOREfor 8 CC licence types addedstorage/migrations/003_seed_license_types.sql— migration created + applied- Verified live DB has
license_typeswith 8 rows
3b — src/Database.php
getLicenseTypes(): arraygetAllLicenseTypes(): array— alias
3c — Add form (public/admin/add.php)
- Loads
$licenseTypes; "Licence"<select name="license_id">added before duration
3d — Add action (public/admin/actions/formulaire.php)
$licenseIdparsed + included in INSERT
3e — Edit form (public/admin/edit.php)
- Loads
$licenseTypes; rawlicense_idFK fetched directly; select pre-populated - POST handler:
license_idincluded in UPDATE
3f — View update
storage/schema.sql— updatev_theses_fullto exposet.license_idraw FK (edit form currently queries theses directly — lower priority)
3g — TFE public page
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 getrole = 'promoteur',is_external = 0storage/schema.sql— add the two new columns tothesis_supervisorsdefinition; updatev_theses_fullto 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 existingsupervisorscolumn (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_ordersetThesisJury(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 checkboxname="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) - checkboxname="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
promoteuricetext input with the same "Composition du jury" fieldset as in add.php; pre-populate fields from$juryarray - 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 toSTORAGE_ROOT, same convention asthesis_files.file_path; NULL if no bannerstorage/schema.sql— addbanner_path TEXTcolumn tothesesdefinition; updatev_theses_fulltoSELECT 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 toSTORAGE_ROOT . "/banners/"with a random hex filename - Path stored as"banners/" . $safeFileName - After thesis INSERT:
UPDATE theses SET banner_path = ? WHERE id = ?(or includebanner_pathin 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_bannerchecked:UPDATE theses SET banner_path = NULL WHERE id = ?; also unlink the file - If new file uploaded: process as in 5c; updatebanner_path
5e — src/Database.php
getThesis()andgetThesisById()— already return all columns from the view; after addingbanner_pathto the view, they automatically expose it — verifygetPublishedTheses()— same: view-sourced, automatic after view update- Optionally add
setBannerPath(int $thesisId, ?string $path): voidfor clarity
6 — Home page: gradient placeholder cards & random ordering
6a — Gradient placeholder for cards without a banner
public/index.php— gradient placeholder using HSL hue from thesis IDpublic/assets/main.css—.card__media--gradient,.card__gradient-author,.card__gradient-titlestyles added
6b — Banner image as card thumbnail
public/index.php— checksbanner_pathfirst, falls through to gradient
6c — Random ordering from the latest year
src/Database.php—getLatestYearTheses(int $limit = 24)+getLatestPublishedYear()public/index.php— default home view uses random latest-year selection; paginated view for?year=Xand?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 columnkeywords.keyword→tags.name - Rename junction table
thesis_keywords→thesis_tags; rename FK columnthesis_keywords.keyword_id→thesis_tags.tag_id - Composite PK on
thesis_tags(tag_id, thesis_id)(tag first — matches the lookup patternWHERE 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: replaceLEFT JOIN keywords k ON tk.keyword_id = k.id … GROUP_CONCAT(DISTINCT k.keyword)withLEFT 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(): querytagstable, columnnamegetUsedKeywords()→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.namebuildSearchConditions: replace thekeywords LIKE :keywordview-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 onv_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→findOrCreateTagandgetUsedKeywords→getUsedTags(remove after all callers updated)
3 — Admin write paths
public/admin/actions/formulaire.php: replacefindOrCreateKeyword+INSERT INTO thesis_keywordswithfindOrCreateTag+INSERT INTO thesis_tagspublic/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; updategetUsedKeywords()call →getUsedTags(); rename GET paramkeyword→tag(keep old param as alias)public/tfe.php:$data['keywords']→$data['tags'](view column rename)templates/search-bar.php(if applicable): update any hardcodedkeywordparam 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 athesis_countcolumnrenameTag(int $id, string $newName): voidmergeTag(int $sourceId, int $targetId): voiddeleteTag(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 forfindOrCreateTaground-triptests/Integration/SearchTest.php: add test for tag-filter search using the new subquery
6 — Fixtures / seed data
storage/fixtures/CreateTestDatabase.php: update to usetags/thesis_tagstable names andfindOrCreateTag()
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 gatepublic/tfe.php— conditional renderingpublic/admin/edit.php— visibility select + context_note textareapublic/admin/index.php— three-state badge + bulk actionspublic/admin/actions/publish.phpor newvisibility.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-serverrecipe (rsync + run setup-server.sh on remote) - Exclude
.claudeand.pifrom rsync deploy - Update
docs/SERVER_SETUP.mdwith 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.shon server)