Files
xamxam/TODO.md
Pontoporeia bc5c50f1fb docs: semantic HTML audit — add section I–VII to TODO.md
Full analysis of every public-facing page and partial against semantic HTML.
Currently only one semantic element exists across the entire public frontend (<nav>).

Key findings mapped to concrete replacements:

nav.php: <div class=site-nav__links> → <ul>/<li>; active class → aria-current=page
         search <form> needs role=search, aria-label, hidden SVG icon

index.php: <div class=cards-container> → <ul>; card <div>s → <li>; <a> wraps directly
           card__media → <figure> for image cards; pagination divs → <nav><ul>
           disabled pagination links need aria-disabled + tabindex=-1, not just a class

search.php: filter label+div groups → <label> wrapping <select> (removes 2 classes per group)
            .search-results-view wrapper → remove (redundant inside <main>)
            results-grid <div> → <ul>; result-card__meta <span> → <small>
            repertoire columns <div> → <section>; link lists → <ul>/<li>
            active links → aria-current=page

tfe.php: heading hierarchy is backwards — author is h1, title is h2; should be reversed
         .tfe-layout → <article>; .tfe-left → <header> inside article
         .tfe-meta-list div+span soup → <dl>/<dt>/<dd> (removes ~30 wrapper divs + 5 classes)
         .tfe-right → <aside>; .tfe-media-block → <figure>; caption → <figcaption>
         .tfe-synopsis-text <div> → <p>; back link wrapper div → remove

apropos.php: .apropos-right <div> → <aside>; contact divs → <address>
             section wrapper divs → <section>; two CSS classes → strong + a[href^=mailto:]
             double-class .apropos-description.apropos-page-content → single .prose

licence.php: remove always-empty right column and two-column layout entirely

Summary table: 25+ classes that become deletable once semantic elements carry the meaning
2026-03-26 22:53:47 +01:00

42 KiB
Raw Blame History

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 Parsedown Markdown; uses apropos.css layout
  • templates/nav.php — add "Licence" link between "Répertoire" and "À Propos"; apply site-nav__link--active when $currentNav === 'licence'
  • 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

  • getPage(string $slug): array|nullSELECT * FROM pages WHERE slug = ?
  • savePage(string $slug, string $content): void — throws if slug not found
  • getAllPages(): array — for listing in admin

2b — Admin pages editor UI

  • public/admin/pages.php — list all editable pages; links to edit each one
  • public/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 (bundled src/Parsedown.php)
  • public/licence.php — renders $db->getPage('licenses') via Parsedown
  • Parsedown bundled as src/Parsedown.php (zero-dependency, MIT)
  • 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 — seed INSERT OR IGNORE for 8 CC licence types added
  • storage/migrations/003_seed_license_types.sql — migration created + applied
  • Verified live DB has license_types with 8 rows

3b — src/Database.php

  • getLicenseTypes(): array
  • getAllLicenseTypes(): 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)

  • $licenseId parsed + included in INSERT

3e — Edit form (public/admin/edit.php)

  • Loads $licenseTypes; raw license_id FK fetched directly; select pre-populated
  • POST handler: license_id included in UPDATE

3f — View update

  • storage/schema.sqlv_theses_full now exposes t.license_id raw FK (done as part of 004/005 view rebuild)

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 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 jury_promoteurs - GROUP_CONCAT(DISTINCT CASE WHEN ts.role='lecteur' THEN s.name END) as jury_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
  • setThesisJury(int $thesisId, array $juryMembers): void — delete + re-insert

4c — Add form (public/admin/add.php)

  • Remove promoteurice and promoteurice_externe fields
  • Add "Composition du jury" fieldset: président·e, promoteur·ice + externe checkbox, dynamic lecteur·ices list with JS add/remove

4d — Add action (public/admin/actions/formulaire.php)

  • Removed old promoteurice parsing; parse jury fields; call $db->setThesisJury()

4e — Edit form (public/admin/edit.php)

  • Load $jury = $db->getThesisJury($thesisId); jury fieldset pre-populated from DB
  • POST handler calls $db->setThesisJury()

4f — TFE public page (public/tfe.php)

  • Three conditional jury rows: Président·e, Promoteur·ice, Lecteur·ices

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.sqlALTER TABLE theses ADD COLUMN banner_path TEXT
  • storage/schema.sqlbanner_path TEXT in theses; t.banner_path in view

5b — Add form (public/admin/add.php)

  • "Image bannière" file input added after couverture row

5c — Add action (public/admin/actions/formulaire.php)

  • Banner upload: MIME check, 5 MB cap, save to banners/, call setBannerPath()

5d — Edit form (public/admin/edit.php)

  • Banner preview img shown; remove_banner checkbox; new banner upload input
  • POST handler: unlinks old file on remove; processes new upload via setBannerPath()

5e — src/Database.php

  • banner_path exposed via view — verified; getThesisById() / getPublishedTheses() pick it up automatically
  • setBannerPath(int $thesisId, ?string $path): void added

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 ID
  • public/assets/main.css.card__media--gradient, .card__gradient-author, .card__gradient-title styles added

6b — Banner image as card thumbnail

  • public/index.php — checks banner_path first, falls through to gradient

6c — Random ordering from the latest year

  • src/Database.phpgetLatestYearTheses(int $limit = 24) + getLatestPublishedYear()
  • 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 keywordstags; column keywordname
  • Rename junction thesis_keywordsthesis_tags; FK keyword_idtag_id
  • PK on thesis_tags(tag_id, thesis_id); idx_tags_name; updated index names
  • Views v_theses_full/v_theses_public use thesis_tags/tags.name
  • Migration storage/migrations/001_rename_keywords_to_tags.sql written and applied

2 — src/Database.php

  • findOrCreateTag() added; findOrCreateKeyword() is a backwards-compat alias
  • getUsedTags() rewritten with proper M2M JOIN; getUsedKeywords() alias kept
  • buildSearchConditions: keyword/query use EXISTS subquery on thesis_tags/tags
  • All conditions prefixed with vp. to match view alias; vp alias added to search queries

3 — Admin write paths

  • public/admin/actions/formulaire.php: uses findOrCreateTag + thesis_tags
  • public/admin/edit.php: DELETE FROM thesis_tags + findOrCreateTag + thesis_tags

4 — Public read paths

  • public/search.php: fixed $kw['keyword']$kw['name'] (tag column rename)
  • getUsedKeywords() alias delegates to getUsedTags() — no functional change needed
  • public/tfe.php: $data['keywords'] still works — view column name unchanged
  • templates/search-bar.php: no keyword param refs — verified

5 — Admin tag management UI (/admin/tags.php)

5a — src/Database.php

  • getAllTagsWithCount(), renameTag(), mergeTag(), deleteTag()

5b — public/admin/tags.php

  • Auth guard, CSRF, table with rename/merge/delete per row, inline forms

5c — public/admin/actions/tag.php

  • Routes on $_POST['action']: rename, merge, delete

5d — Nav & routing

  • templates/admin/head.php: "Mots-clés" nav link added

5e — Propagation safety

  • mergeTag() uses INSERT OR IGNORE to avoid PK conflicts; deleteTag() cascades via FK

6 — Tests

  • tests/Unit/DatabaseTest.php: tests 57 cover findOrCreateTag, getUsedTags, alias
  • tests/Integration/SearchTest.php: tests 46 cover tag-filter subquery, full-text query, count consistency

6 — Fixtures / seed data

  • storage/fixtures/CreateTestDatabase.php: updated to tags/thesis_tags/findOrCreateTag()

Feature: Mode Maintenance

  • Storage flag file storage/maintenance.flag (created on demand)
  • Public gate in config/bootstrap.php — blocks non-admin routes when flag exists
  • public/maintenance.php (503 page, minimal dark UI)
  • public/admin/actions/maintenance.php (POST: enable/disable)
  • Admin UI toggle in public/admin/index.php (bar with status + action button)

Feature: TFE Visibility States (publique / interne / interdit)

  • DB migration 002_add_visibility.sql — seeds access_types rows (already existed)
  • src/Database.phpsetVisibility(), bulkSetVisibility(), getAccessTypes()
  • public/media.php — blocks thesis files when access_type_id = 3 (Interdit)
  • public/tfe.php — shows access type, context_note, hides files for Interdit
  • public/admin/edit.php — access_type_id select + context_note textarea
  • public/admin/index.php — three-state access badge per row
  • public/admin/actions/visibility.php — single + bulk visibility update

Fixes

  • Fix tests/Security/SecurityTest.php: update SQL injection test to call searchTheses(['query' => $string]) instead of bare string — searchTheses() signature was updated to array $params but the test was never updated, causing a fatal TypeError that prevented the security suite from running at all

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
  • Remove 100-item cap from répertoire student index: getAllPublishedTheses() fetches all published theses; search results remain paginated at 30/page
  • Cover image fallback for home grid cards: batch-load thesis_files covers for theses without banner_path; resolution order: banner → cover → gradient

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 viewer to admin panel: "nginx — config" tab in system.php reads /etc/nginx/sites-available/posterg (live, with badge) or falls back to local nginx/posterg.conf; line-numbered, syntax-coloured, copy-to-clipboard
  • Add admin user management UI — password change/set for PHP auth layer (public/admin/account.php + actions/account.php; "Compte" nav link; account CSS)
  • Merge status.php and logs.php into a single system.php page; remove "Statut" and "Journaux" nav links, add single "Système" link; preserve all existing content in their respective sections
  • Rework logs UI: replace the select-then-click-Afficher flow with instant tabs (nginx access, nginx error, php-fpm); switching tabs loads the selected log immediately without a form submit; add a copy-to-clipboard button per log view

Refactor & Maintenance (backend audit 2026-03-26)

A — SQLite / Query performance

  • WAL mode — set PRAGMA journal_mode = WAL and PRAGMA synchronous = NORMAL in Database::__construct() after foreign_keys = ON. Eliminates full-database read-locks on every write; makes concurrent PHP-FPM workers safe. Also add PRAGMA cache_size = -8000 (≈8 MB page cache) while there.

  • Composite index (is_published, year DESC) on theses — every public query filters on both. Currently idx_theses_published and idx_theses_year are separate; the query planner picks one and sorts the other with a temp B-tree. A single covering index eliminates the sort: CREATE INDEX IF NOT EXISTS idx_theses_pub_year ON theses(is_published, year DESC); Add to schema.sql + a migration.

  • v_theses_full is always fully materialised — every query against v_theses_public forces SQLite to expand the entire view CTE (all 15 JOINs + 8 GROUP_CONCAT temp B-trees) before applying the WHERE/LIMIT. The view cannot be used with an index. Fix: replace v_theses_public with a plain table query in searchTheses and getPublishedTheses — build the minimal JOIN set per query and let indexes filter theses first. Keep the views for reporting/admin use only.

  • getAllPublishedTheses() in search.php — fetches every published thesis (all columns, all JOINs) just to build the student-name index on the Répertoire page. This is a full table scan through the fat view. Replace with a dedicated lean query: SELECT id, authors FROM v_theses_public ORDER BY authors ASC — or add Database::getPublishedAuthors(): array that queries thesis_authors JOIN authors directly, avoiding the view entirely.

  • migration 005 view is stale in the file005_add_banner.sql recreates the view still referencing thesis_keywords / keywords.keyword (the old pre-migration-001 names). The file is already applied to the live DB (correctly, since 001 ran first), but the migration file itself is wrong and misleading. Fix: rewrite it to reference thesis_tags / tags.name, or drop the view recreation from it (schema.sql is the canonical source).

B — PHP / Database.php

  • Dead CRUD helpersgetOrientationId(), getAPProgramId(), getFinalityId(), getLanguageId(), getFormatId() are defined in Database.php but never called anywhere (forms now pass IDs directly from selects). Remove them to reduce surface area.

  • Alias proliferationgetOrientations()/getAllOrientations(), getApPrograms()/getAllAPPrograms(), getFinalityTypes()/getAllFinalityTypes(), getLanguages()/getAllLanguages(), getFormatTypes()/getAllFormatTypes(), getLicenseTypes()/getAllLicenseTypes(), findOrCreateKeyword(), getUsedKeywords()13 alias methods pointing at 6 real ones. Pick the canonical name for each pair, update all call-sites (there are few), and delete aliases. Reduces Database.php from ~945 lines significantly.

  • getPDO() / getConnection() leaking to callersedit.php, formulaire.php, thanks.php, import.php, tfe.php, index.php, media.php, system.php all call $db->getPDO() or $db->getConnection() to run raw queries that belong in Database.php. Each is a missed encapsulation: - tfe.php: raw SELECT access_type_id FROM theses WHERE id = ? → add getThesisAccessTypeId(int $id): ?int - index.php: raw SELECT thesis_id, file_path FROM thesis_files WHERE … IN (…) → add getCoverPathsForTheses(array $ids): array - media.php: raw visibility join → move into Database::getFileVisibility(string $path): ?int - edit.php (line 155): unparameterised "… WHERE id = $thesisId" SQL injection risk — fix immediately; also move to a DB method - edit.php: raw SELECT license_id, access_type_id, context_note FROM theses WHERE id = ? → expose these via getThesis() (already returns v_theses_full which has license_id) - formulaire.php: raw identifier-generation query + all junction-table INSERTs → encapsulate in Database::createThesis(array $data): int

  • sanitize_string() in formulaire.php applies htmlspecialchars at write time — HTML-escaping belongs at render time (in the template), not at storage time. Storing &amp; or &lt; in the DB means search, export, and any non-HTML consumer sees corrupt data. Remove htmlspecialchars from sanitize_string(); keep only trim(). The templates already call htmlspecialchars() on output.

  • Dead variable $problematiqueformulaire.php line 84 reads $_POST["problématique"] into $problematique but the value is never used (no matching column, no INSERT reference). Delete it.

  • setThesisJury() not wrapped in a transaction — the method does a DELETE then multiple INSERTs with no transaction guard of its own. If called from outside a transaction (e.g. a future API endpoint) a partial failure leaves orphaned rows. Wrap the body in BEGIN … COMMIT / ROLLBACK (check $this->pdo->inTransaction() to avoid nesting).

  • DB config auto-detection is fragilesrc/config.php switches to test.db whenever the file exists locally, which means a developer who ran tests and forgot to delete test.db will silently hit test data on a local production-mirror. Make the default prod; require explicit DB_ENV=test to use the test database.

C — Code organisation / maintainability

  • edit.php does too much — 530 lines combining form display, POST handling, file upload, and all reference-data loading in one file. Extract the POST handler to public/admin/actions/edit.php (matching the pattern already used by formulaire.php, tag.php, page.php, etc.).

  • formulaire.php duplicates banner-upload logic verbatim from edit.php — the MIME check, size cap, random_bytes name, chmod, and setBannerPath() call are copy-pasted. Extract a private Database-level helper or a standalone uploadBanner(array $file): ?string utility function (returns the relative path or null) shared by both action files.

  • Junction-table INSERTs are open-coded in every actionformulaire.php and edit.php both manually loop INSERT INTO thesis_languages, thesis_formats, thesis_tags. Add Database::setThesisLanguages(int $id, array $ids), setThesisFormats(int $id, array $ids), setThesisTags(int $id, array $names) — following the same delete-then-reinsert pattern already used by setThesisJury().

  • RateLimit uses per-file JSON on disk — reads, writes, and glob()s the filesystem on every public request. For a low-traffic art-school site this is fine, but it creates a write-on-every-hit pattern. Consider switching to APCu (if available) or SQLite (single INSERT) to avoid filesystem churn. At minimum, move the cache dir to /tmp or a dedicated storage/cache/ path that is excluded from deploy rsync.

  • __wakeup() singleton guard throws from a public method — PHP 8.x deprecates throwing exceptions from __wakeup. Change to trigger_error(…, E_USER_ERROR) or implement __serialize()/__unserialize() that always throw.


Refactor & Maintenance — Templates & Frontend (audit 2026-03-26)

D — Template structure / boilerplate duplication

  • Every public page duplicates its own <head>index.php, search.php, tfe.php, apropos.php, licence.php each contain an identical block: <!DOCTYPE html>, <html lang="fr">, <meta charset>, <meta viewport>, <link rel="icon">, <link modern-normalize>, <link common.css>, live-reload script. Only <title> and one extra CSS <link> differ. Extract a templates/public/head.php partial accepting $pageTitle and $extraCss — mirrors the pattern templates/admin/head.php already uses.

  • Live-reload snippet copy-pasted into 6 filesindex.php, search.php, tfe.php, apropos.php, licence.php, templates/admin/head.php all contain the same 6-line (function poll(){…})() block. Consolidate into the shared head partials.

  • templates/header.php and templates/head.php are dead files — neither is included anywhere in the codebase. Both contain outdated markup from a previous design iteration (lang="en", empty author meta, a broken nav with double-quoted href attributes inside href). Delete both to remove confusion.

  • public/assets/icons.svg is dead — it is the full TrumboWYG editor icon sprite (40+ symbols) referenced nowhere in the codebase. The only WYSIWYG editor in use (EasyMDE in pages-edit.php) loads from CDN. Delete icons.svg (~15 KB of noise).

  • admin_favicon.svg used as the public-facing favicon — every public page links /assets/admin_favicon.svg. Rename or create a distinct favicon.svg so admin and public can diverge without naming confusion.

E — CSS architecture

  • html, body { margin:0; padding:0; height:100% } repeated in 4 page stylesheetsmain.css, search.css, tfe.css, apropos.css all open with this identical block. Move it to common.css once; delete from the four files. Same for the body-level flex-column shell (display:flex; flex-direction:column; background:var(--white)) which only differs in the BEM class name applied to <body>.

  • No font-display on the Combinedd.otf custom fontcommon.css declares @font-face with no font-display property; the browser blocks text rendering until the font loads (FOIT). Add font-display: swap. Also add a <link rel="preload"> for the font file in the shared head partial once it exists.

  • Search results pagination is fully inline-styledsearch.php lines 159164 apply style="padding:.25rem .7rem;border:1px solid #ddd;…" and hardcoded #ddd/#666. The home page (index.php) already has .pagination-btn / .pagination-info in main.css. Reuse those classes in search.php and remove the inline styles.

  • Scattered inline styles in templates — notable instances that should become named classes: - tfe.php line 146: style="align-items:start;".tfe-meta-item--top in tfe.css - tfe.php lines 148, 170172, 193: font-style:italic, margin-top:1.5rem, font-size:.88rem;color:#666, color:#999;font-style:italic.tfe-note-value, .tfe-back-link, .tfe-restricted in tfe.css - admin/edit.php: multiple style= on .admin-form-row and banner preview → modifier classes in admin.css - index.php line 146: style="padding:2rem;color:#666;".cards-empty in main.css

  • .site-nav__right is a duplicate of .site-nav__linkcommon.css defines both with identical declarations (font-size, letter-spacing, text-transform, color, opacity, transition). The only difference is DOM position. Merge .site-nav__right into .site-nav__link; let the flex layout position it via margin-left:auto or DOM order.

  • .site-nav__link--active is applied in nav.php but never defined in CSS — the class is set conditionally but has no corresponding rule in common.css, so the active state is invisible. Add a visible style (e.g. opacity:1; border-bottom:1px solid rgba(255,255,255,.6)) or remove the conditional.

F — Template logic / PHP in templates

  • Rate-limit 429 response in search.php emits unstyled bare HTML — the early-exit block outputs <!DOCTYPE html><html><body><h1>Trop de requêtes</h1>… with no stylesheet, no lang, no viewport meta. Style it inline-minimally or redirect to a consistent 429.php page (like maintenance.php).

  • apropos.php contacts and credits are hardcoded in the template — names, roles, emails (Laurent Leprince, Xavier Gorgol, Brigitte Ledune) and credits text live in PHP/HTML and require a code deploy to change. Either move them into the about page Markdown (admin- editable) or extract to a config array so they are in one place.

  • licence.php wastes half the viewport with an always-empty right column — the page reuses the two-column .apropos-layout but <div class="apropos-right"></div> is always empty. Add a .apropos-layout--single variant (or just grid-template-columns:1fr when the right child is empty) to use the full width for content.

G — Accessibility & semantics

  • <nav> in nav.php has no aria-label — pages have multiple landmark regions (main nav, search <form>, pagination). Add aria-label="Navigation principale" to the <nav> and aria-label="Pagination" to pagination wrappers so screen readers can distinguish them.

  • Search bar <form> has no accessible namesearch-bar.php has no aria-label on the <form> and no <label> for the input (only a placeholder). Add aria-label="Recherche" to the <form> element.

  • No <meta name="description"> on any public page — all public pages omit the description meta tag (the dead templates/head.php had content=""). Add per-page descriptions: site blurb for index.php, synopsis excerpt for tfe.php, page content intro for apropos.php/licence.php. Necessary for search indexing and link preview cards.

  • No Open Graph tagstfe.php is the ideal candidate for og:title, og:description (synopsis), og:image (banner or cover path through /media.php), og:type=article. Without them, sharing a thesis link on social media or messaging apps shows a blank preview.

H — Minor / low-hanging fruit

  • admin/thanks.php duplicates getThesisFiles() with a raw PDO query — lines 3440 manually prepare SELECT … FROM thesis_files WHERE thesis_id = ? instead of calling $db->getThesisFiles($thesisId) which already exists. Replace with the DB method.

  • admin/index.php stats computed via PHP array_filter on full result set — "total", "publiés", "en attente" counts are derived by filtering the already-fetched $theses array in PHP. When a filter is active the stats reflect only filtered rows, which is misleading. Add Database::getThesesStats(): array returning three counts from SQL (COUNT(*), SUM(is_published), SUM(1-is_published)) so they always reflect the full DB.


Semantic HTML audit (2026-03-26)

Goal: replace presentational wrappers with the element that already carries the correct meaning, removing classes where the element name itself is sufficient as a CSS selector. The design does not need to change — only the vocabulary of the markup.

I — templates/nav.php & templates/search-bar.php

  • <div class="site-nav__links"> wraps the centre nav links purely for flex grouping. Replace with <ul> + <li> children (links inside a nav belong in a list — standard pattern). The <a> elements stay; CSS targets nav ul / nav li / nav a directly, removing .site-nav__links, .site-nav__link, .site-nav__right classes entirely. nav a[aria-current="page"] replaces the missing .site-nav__link--active rule and is self-documenting.

  • <form class="site-search"> is already a <form> — good. Add role="search" and aria-label="Recherche". The SVG icon should get aria-hidden="true" (it's decorative). The <input> should have an associated <label> (visually hidden via .sr-only is fine, or aria-label on the input).

II — public/index.php

  • <div class="filter-info"> is a status/notice banner. Use <p role="status"> or <output> — both carry live-region semantics for screen readers without extra ARIA.

  • <div class="cards-container"> is a list of navigable items. Replace with <ul> — removing the wrapper div and making each card an <li>. .cards-container → target main > ul or a single class on <ul>.

  • <a class="card-link"><div class="card">…</div></a> — the outer <a> wrapping a <div> makes the div redundant. The <a> is already a block element (set display:block). The .card div can be removed; CSS targets ul li a directly. The <li> inside the <ul> becomes the card container.

  • <div class="card__media"> — this is the image/media wrapper inside each card. When it contains an <img>, use <figure> (a self-contained media unit). When it shows the gradient placeholder (no real image), a plain <div> is fine since it's presentational.

  • <div class="card__info"><p class="authors">…</p></div> — the .card__info wrapper exists only to add padding. Move the padding to the <p> or <li> directly; remove the div. The <p> stays. .authors class → either keep it or target li > p.

  • <div class="pagination-wrap"> with <a class="pagination-btn"> and <span class="pagination-info"> — replace with <nav aria-label="Pagination"><ul>…</ul></nav>. Each button becomes an <li>. The disabled state uses aria-disabled="true" + tabindex="-1" instead of a .disabled class alone (which has no keyboard semantics). <span class="pagination-info"><li aria-current="page">1 / 5</li>.

III — public/search.php

  • <div class="search-filter-group"> wraps each label+select pair. Replace with <label> directly wrapping <select> — one element instead of two, and the label/control association is implicit. Remove .search-filter-group and .search-filter-label (the <label> element is the label). CSS targets form label and form select.

  • <span class="search-filter-label"> inside the filter group — deleted once the <label> approach is taken (see above).

  • <div class="search-results-view"> is unnecessary nesting inside <main>. <main> is already the landmark. Remove the wrapper; apply padding directly to <main> or its direct children.

  • <div class="results-grid"> is a list of search results. Replace with <ul class="results-grid">. Each <a class="result-card"> becomes a <li><a> — the link text is made up of child <span>s which is correct. However .result-card__authors and .result-card__title <span>s would be better as <strong> (author, emphasis) and the title as plain text or <span>. The year/meta <span class="result-card__meta"><small> (ancillary metadata).

  • Répertoire index: <div class="repertoire-index"> — replace with <div> kept but its four children are semantic candidates: each .repertoire-col is an independent index with a heading. Replace <div class="repertoire-col"> with <section>. The heading (<h2 class="repertoire-col__header">) is already correct — <h2> is right. Remove .repertoire-col__header; CSS targets section > h2 scoped inside .repertoire-index.

  • .year-index-item, .cat-index-item, .student-index-item, .keyword-index-item — all four are sequences of <a> links with display:block. They are lists. Wrap each group in <ul>; each link becomes <li><a>. The four custom classes collapse to a single ul a selector per column (or no class at all, scoped via section). The .active class on links → aria-current="page" on the <a>.

  • <p class="search-results-header"> count line — remove .search-results-header; this is a plain <p> styled with .search-main p:first-child or just keep a lightweight class. Or use <output> since it is a computed result count.

IV — public/tfe.php

  • <div class="tfe-layout"> — the two-column grid container. Replace with <article> — a single thesis is genuinely a self-contained piece of content. Remove .tfe-layout; CSS targets main > article for the grid. Remove .tfe-main; CSS targets main directly.

  • <div class="tfe-left"> — the info/metadata column. Replace with <header> of the article (it contains the author name, title, and all metadata — the article header). Or simply remove and target article > :first-child if that is too strong. Actually <header> is semantically correct here: it is the identifying header of the article.

  • <div class="tfe-meta-list"> with <div class="tfe-meta-item"><span class="label">…</span><span class="value">…</span></div> — this is a description list by definition. Replace with <dl> / <dt> / <dd>: - <dl class="tfe-meta-list"> → just <dl> (class optional) - <div class="tfe-meta-item"> → remove; <dt>+<dd> are direct children of <dl> (or grouped in <div> inside <dl> which is valid HTML — the spec allows it for styling) - <span class="label"><dt> - <span class="value"><dd> - CSS: .tfe-meta-listdl; .labeldl dt; .valuedl dd This removes ~5 classes and ~30 wrapper divs from the metadata section.

  • <div class="tfe-synopsis-text"> — the synopsis paragraph(s). Replace with <p> (or keep as <section class="synopsis"> if multi-paragraph, but a single <p> suffices for most cases). Remove the wrapper div.

  • <div style="margin-top:1.5rem;"><a href="index.php" style="…">← Retour</a></div> — remove the wrapper div; move margin to the <a> itself as a class. The back link is better as <a rel="up" href="index.php" class="back-link">← Retour</a> (no wrapper needed).

  • <div class="tfe-right"> — the media column. Replace with <aside> — it contains supplementary files (media, PDFs) that are related but secondary to the descriptive content. Remove .tfe-right; CSS targets article > aside.

  • <div class="tfe-media-block"> — each file display unit. Replace with <figure>. Image and video files become <figure><img></figure> and <figure><video></video></figure>. The existing <p class="tfe-file-caption"><figcaption>. PDF <embed> stays in a <figure> (valid). Remove .tfe-media-block; CSS targets aside figure.

  • <h1 class="tfe-author"> and <h2 class="tfe-title"> — the heading hierarchy makes the author the primary heading and the title secondary, which is backwards semantically. The title of the work is the <h1>; the author is metadata (could be a <p> or a <dt> in the <dl> above). Swap: <h1> = title, author moves into the <dl>. Keeps the visual design (CSS controls size) but fixes the document outline.

V — public/apropos.php

  • <div class="apropos-layout"> — two-column grid. Replace with <div> kept but the children are semantic: left is the main content, right is supplementary. Left <div class="apropos-left"> → remove (redundant wrapper around already-styled content). Right <div class="apropos-right"><aside> (contacts, credits = supplementary info).

  • <div class="apropos-description apropos-page-content"> inside the left col — the Parsedown output already generates <p>, <h1><h3>, <ul> etc. The wrapping <div> is only needed for the .apropos-page-content scoped CSS rules. Keep it but as a single class — <div class="prose"> — and scope all Markdown content styles under .prose. This is the standard prose-container pattern.

  • <div class="apropos-contact"> — each contact entry. Replace with <address>: the HTML spec defines <address> for contact information related to the document or section. Each contact is literally an address entry. <span class="apropos-contact-name"><strong>, <span class="apropos-contact-role"> → plain text or <span>, <span class="apropos-contact-email"><a href="mailto:…">. Three classes removed.

  • Outer <div> wrappers around each section in .apropos-right (<div><h2>…</h2></div>, <div><h2>Contacts</h2>…</div>, <div><h2>Crédits</h2>…</div>) — replace each with <section>. Remove the anonymous <div> wrappers; CSS targets aside section > h2.

VI — public/licence.php

  • <div class="apropos-right"></div> — always-empty right column. Remove entirely; the licence.php page is full-width content. Update licence.php to not use .apropos-layout at all — just <main class="apropos-main"><div class="prose">…</div></main>. No class changes needed to apropos.css; the layout simply is not applied.

VII — Summary of class deletions enabled by semantic changes

Once the above is applied, the following classes become deletable (element name carries the meaning):

Class removed Replaced by
.site-nav__links nav ul
.site-nav__link nav li a
.site-nav__right nav li:last-child a (or [aria-label] target)
.site-nav__link--active [aria-current="page"]
.card-link ul li a (block <a> inside <li>)
.card ul li
.tfe-layout main > article
.tfe-left article > header
.tfe-right article > aside
.tfe-meta-list dl
.tfe-meta-item div inside dl (or removed)
.label / .value dt / dd
.tfe-media-block figure
.tfe-file-caption figcaption
.tfe-synopsis-text p (direct child of article > header)
.search-filter-label label
.search-filter-group label (wrapping approach)
.repertoire-col section
.repertoire-col__header section > h2
.year-index-item etc. ul a (scoped per section)
.result-card__meta small
.results-grid ul.results-grid (only class needed)
.apropos-left removed (direct child of grid)
.apropos-right aside
.apropos-contact address
.apropos-contact-name strong inside address
.apropos-contact-email a[href^="mailto:"] inside address
.apropos-description apropos-page-content .prose (single class)