The jury composition fieldset (président·e, promoteur·ice + external checkbox, dynamic
lecteur·ices list with JS add/remove) was copy-pasted verbatim between the two longest
admin forms.
- Created templates/partials/form/jury-fieldset.php
- Consumes $juryPresident, $juryPromoteur, $juryPromoteurExt, $juryLecteurs[]
- Handles both add-mode (falls back to old()/wasSelected() flash helpers) and
edit-mode (pre-populates from DB-loaded variables)
- $juryIdx initialised from max(count($juryLecteurs), 1) — correct for both modes
- add.php: 311 → 251 lines (-60); entire fieldset + <script> replaced with one require
- edit.php: 359 → 289 lines (-70); PHP variable extraction kept inline before require
The pagination nav was duplicated between public/index.php and public/search.php
with structural differences: index.php used string concatenation for query params
and had first/last-page buttons (« »); search.php used http_build_query but had
only prev/next (‹ ›) and a flat <span> rather than a <ul>/<li> structure.
- Add templates/partials/pagination.php: accepts $page, $totalPages, $baseParams[]
(any array of query params to preserve); builds URLs with http_build_query;
renders a semantic <nav>/<ul>/<li> block with first/prev/info/next/last buttons,
correct aria-disabled + tabindex on disabled links, and aria-label on each button.
Returns immediately (no output) when $totalPages <= 1.
- Replace inline pagination block in index.php with:
$baseParams = array_filter(['year' => $year]);
include pagination.php
- Replace inline pagination block in search.php with:
$baseParams = array_diff_key($_GET, ['page' => '']);
include pagination.php
This also upgrades search.php to the full first/last button set it was missing.
Both callers verified with php -l. No functional change to existing behaviour.
CSS: .site-search → header form[role="search"],
.site-search__icon → header form[role="search"] svg,
.site-search__input → header form[role="search"] input,
.site-search__input::placeholder → header form[role="search"] input::placeholder
HTML: Removed class="site-search", class="site-search__icon", and
class="site-search__input" from header.php and search-bar.php.
The form already uses role="search" and contains a single svg + input,
so the semantic selectors are unambiguous.
Create the central App helper that eliminates ~170 lines of duplicated
bootstrap/auth/CSRF preamble across 24 page and action handler files.
src/App.php provides:
- boot(): loads Database + ensures CSRF token (public pages)
- adminGuard(): requires AdminAuth login + boot (admin pages)
- verifyCsrf() / rotateCsrf(): centralised CSRF lifecycle
- flash() / consumeFlash(): unified flash messages with legacy key drain
(error, success, admin_error, admin_success, edit_error, edit_success,
form_error all consumed transparently for incremental migration)
- redirect(): flash + Location header + exit in one call
- render(): head → header → content → footer pipeline with auto admin
footer selection
App.php is auto-loaded from config/bootstrap.php so all existing pages
get the class for free without any changes.
templates/partials/flash-messages.php uses App::consumeFlash() to replace
the 5+ copy-pasted flash blocks across admin templates.
All existing tests pass. No existing page files modified — this is a
non-breaking addition that enables incremental controller extraction.
templates/admin/head.php:
- admin-nav__logo now href="/" with target="_blank" rel="noopener noreferrer"
- Left arrow prefix (← via ←, aria-hidden) signals leaving admin
- sr-only suffix "(site public, nouvel onglet)" for screen readers
public/admin/login.php:
- Same treatment on the standalone login nav (was a bare <span>)
public/assets/css/admin.css:
- admin-nav__list: flex:1 removed; margin-left:auto added
→ entire link list now right-justified inside the nav bar,
mirroring the layout of the public site header
- .admin-nav__logout { margin-left:auto } removed (no longer needed;
logout is just the last item in a right-aligned list)
- pages-edit.php: EasyMDE CDN JS URL moved to $extraJs (rendered by footer.php before </body>);
inline EasyMDE init block moved to $extraJsInline, emitted by footer.php via new
`<?php if (!empty($extraJsInline))` guard - fixes invalid <script> floating in <body> (WCAG 4.1.1)
- pages-edit.php: add <small> keyboard-trap hint below the editor textarea:
'Appuyez sur Échap pour quitter l'éditeur au clavier.' (WCAG 2.1.2)
- templates/admin/footer.php: extend to support $extraJsInline (raw inline script string)
- index.php: add <h1 class="sr-only">Mémoires de l'ERG</h1> inside <main> so the page has
a document heading (WCAG 2.4.6; h2 columns in search.php already had a sr-only h1)
- TODO.md: mark completed items as [x]: skip links (2.4.1), focus-visible / outline:none
removal (2.4.7), search.php h1 + index.php h1 (2.4.6), pages-edit.php invalid HTML (4.1.1),
EasyMDE keyboard trap hint (2.1.2)
- templates/admin/head.php: all 7 nav links (+ conditional Modifier + Déconnexion)
wrapped in <ul class="admin-nav__list">/<li>; .active class removed, replaced
with aria-current="page" on each <a> based on $currentPage match
- Déconnexion link: removed inline style="margin-left:auto;opacity:.6;"; moved to
new .admin-nav__logout <li> class in admin.css
- public/assets/admin.css: replaced .admin-nav__link rules with .admin-nav__list a
selectors; added .admin-nav__list (flex list, gap 2.5rem, flex:1); added
.admin-nav__list a[aria-current="page"] rule (border-bottom underline indicator);
added .admin-nav__logout / .admin-nav__logout a for the push-right logout item
- Removes .admin-nav__link class entirely from the codebase (was only used in
templates/admin/head.php and admin.css)
Fixes WCAG 2.4.6 (nav landmark content model), 1.4.1 (colour-only active indicator),
and section VIII of the semantic HTML admin audit.
templates/nav.php:
- Replace <div class="site-nav__links"> with <ul role="list"> + <li> children
- Move À Propos link inside the list (was a loose sibling <a>)
- Remove .site-nav__link and .site-nav__link--active classes from all <a> elements
- Active state now driven solely by aria-current="page" (already present)
public/assets/common.css:
- Remove .site-nav__links, .site-nav__link, .site-nav__link:hover, .site-nav__link--active rules
- Add .site-nav ul (flex, gap, list-style reset), .site-nav ul a, .site-nav ul a:hover
- Active indicator: .site-nav ul a[aria-current="page"] — self-documenting, screen-reader-announced
Fixes TODO section I (nav semantic HTML audit). All three BEM nav-link classes deleted;
zero references remain in the codebase.
- templates/public/head.php: add centralised OG/Twitter tag rendering via $ogTags array;
supports type, title, description, url, image, image_alt, site_name, article_author,
article_published_time; twitter:card switches between summary_large_image / summary
based on presence of og:image
- public/tfe.php: populate full article OG tags — og:type=article, canonical URL,
og:image resolved from banner_path → first image file in thesis_files → omitted,
og:image:alt, article:author, article:published_time (year-01-01); twitter:card
summary_large_image when image present
- public/index.php, search.php, apropos.php, licence.php: add basic og:type=website
tags (title, description, canonical url, site_name)
Sharing a thesis link on Slack, WhatsApp, iMessage, or any social platform will now
render a rich preview card with the thesis title, synopsis excerpt, and cover/banner image.
- templates/nav.php: add aria-label="Navigation principale" to <nav>; emit
aria-current="page" on the active link alongside the existing CSS class
so screen readers announce the current page without relying on colour/style alone
- templates/search-bar.php: add role="search" + aria-label="Recherche" to
the <form>; add a visually-hidden <label for="site-search-input"> linked to
the input via id="site-search-input", satisfying WCAG 3.3.2 (labels/instructions)
and 4.1.2 (name/role/value) — placeholder text alone is not a label
- public/assets/main.css: add @media (prefers-reduced-motion: reduce) block that
sets transition:none and transform:none on .card__media img/video hover, so the
scale(1.02) zoom is fully suppressed for users who opt out of motion (WCAG 2.3.3 /
prefers-reduced-motion); the global transition-duration guard in common.css already
covers all other transitions but does not zero the transform value itself
Fixes TODO sections: G (nav/search-bar landmark names), I (site-search form ARIA),
3.3.2 (search input label), prefers-reduced-motion (card hover transform gate)
Move the repeated 'html, body { margin:0; padding:0; height:100% }' block from
main.css, search.css, tfe.css, and apropos.css into the single canonical location
in common.css. All four public page stylesheets already load common.css first, so
the rule applies identically — no visual change.
Fix pages-edit.php invalid HTML: the EasyMDE <link rel=stylesheet> was placed
inside <body> (after head.php was already closed), which is invalid. Add an
$extraCss hook to templates/admin/head.php so pages can inject <link> tags into
<head> via an array variable, matching the pattern already used by the public
templates/public/head.php. Also add a symmetric $extraJs hook to
templates/admin/footer.php for future use. pages-edit.php now sets
$extraCss = ['easymde.min.css'] before requiring head.php; the EasyMDE JS
<script> and its inline init remain in <body> in the correct load order.
Create templates/public/head.php accepting $pageTitle and $extraCss (array of
stylesheet hrefs), mirroring the existing templates/admin/head.php pattern.
The partial emits: DOCTYPE, <html lang=fr>, charset/viewport meta, favicon,
modern-normalize, common.css, any extra CSS links, and the dev-only live-reload
script. The live-reload snippet was previously copy-pasted verbatim into all
five public pages.
Updated pages:
- public/index.php ($pageTitle='Posterg', $extraCss=['assets/main.css'])
- public/search.php ($pageTitle='Répertoire – Posterg', search.css)
- public/tfe.php ($pageTitle=thesis title + suffix, tfe.css)
- public/apropos.php ($pageTitle='À Propos – Posterg', apropos.css)
- public/licence.php ($pageTitle=DB title + suffix, apropos.css)
tfe.php: removed redundant htmlspecialchars() call on $pageTitle (the partial
applies it); licence.php: renamed conflicting $page variable to $dbPage to
avoid collision with the shared $pageTitle expected by the partial.
All syntax checks and test suite pass (4/4).
- common.css: add font-display: swap to Combinedd.otf @font-face (eliminates FOIT)
- common.css: remove duplicate .site-nav__right block (identical to .site-nav__link);
update nav.php to use .site-nav__link on the À Propos link
- common.css: add .site-nav__link--active rule (opacity:1 + white underline); the class
was already applied in nav.php but had no CSS definition, making it invisible
- search.php: replace fully inline-styled pagination with .pagination-wrap / .pagination-btn
/ .pagination-info classes; add aria-disabled + tabindex=-1 on disabled links;
add aria-label on prev/next links
- search.css: add pagination rule block to match, keeping styles co-located with the page
- Delete templates/header.php and templates/head.php — both were legacy
partials from a previous design iteration (lang="en", broken nav markup)
that were never included anywhere in the current codebase.
- Delete public/assets/icons.svg — the full TrumboWYG icon sprite (~15 KB)
referenced nowhere; the only active WYSIWYG editor (EasyMDE in
pages-edit.php) loads its own assets from CDN.
- Fix licence.php layout: the page was borrowing the two-column
.apropos-layout grid but leaving the right column always empty, wasting
~40% of the viewport. Removed the grid wrapper and the empty .apropos-right
div. Added .apropos-single utility class to apropos.css (max-width: 720px)
so licence content now spans the full available width with a readable
line length.
SQLite performance (Database::__construct):
- PRAGMA journal_mode = WAL: eliminates full-DB read locks on write, safe
for concurrent PHP-FPM workers
- PRAGMA synchronous = NORMAL: durable on commit without full fsync per write
- PRAGMA cache_size = -8000: ~8 MB page cache per connection
Accessibility foundation (WCAG 2.1 AA):
- common.css: add .sr-only utility, .skip-link (hidden until focused),
global :focus-visible (2px purple outline, 2px offset),
prefers-reduced-motion guard; remove bare outline:none from
.site-search__input
- admin.css: same :focus-visible, skip-link, and motion guard scoped to
admin purple; remove outline:none from .admin-input/.admin-select/
.admin-textarea and .admin-filters select (both had :focus border rules
already, so focus is still visually communicated)
- search.css: remove outline:none from .search-filter-select (already has
:focus border-color rule)
- All 5 public pages (index, search, tfe, apropos, licence): add
<a href="#main-content" class="skip-link"> as first child of <body>;
add id="main-content" to <main>
- templates/admin/head.php: same skip link; aria-label="Navigation admin"
on <nav>; id="main-content" on all 10 admin <main> elements
All 4 test suites pass (unit, integration, security, rate-limit).
Replace the separate /admin/status.php and /admin/logs.php pages with a
single /admin/system.php page organised around a tab bar.
- system.php — top-level tab bar: 'Statut' + one tab per log file
(nginx accès, nginx erreurs, PHP-FPM). Switching tabs is a plain
href (?tab=…) so no JS required for navigation; the lines-selector
SELECT triggers a location change on 'change' for instant reload
without a submit button.
- Status tab preserves all existing service cards, PHP runtime grid,
and disk-usage bar from the old status.php.
- Log tabs preserve line-count selector, file metadata bar, and
per-line colour coding from the old logs.php.
- New: copy-to-clipboard button on each log output block (Clipboard
API with textarea execCommand fallback).
- status.php / logs.php replaced with 301 redirect stubs so existing
bookmarks and links keep working.
- templates/admin/head.php: 'Statut' + 'Journaux' nav items replaced
with a single 'Système' item; active state covers all three page
names for redirect compatibility.
Implements the admin user management UI as a self-contained PHP password
change/set flow — no SSH or sudo required.
- public/admin/account.php: shows auth status (PHP hash present, credentials
file path), password change form (requires current password when one exists,
min 12 chars, confirm field), and a danger-zone form to delete the
credentials file entirely
- public/admin/actions/account.php: CSRF-guarded POST handler; verifies
current password via AdminAuth::login() before accepting a new one;
generates bcrypt (cost 12) hash; writes config/admin_credentials.php
atomically via a temp file + rename; regenerates session on success;
redirects to /admin/login.php when credentials are deleted
- templates/admin/head.php: 'Compte' nav link added (active on account.php)
- public/assets/admin.css: .admin-account-status, .admin-section-title,
.admin-field-hint, .admin-danger-zone component styles added
Note: the nginx htpasswd flow (manage-admin-users.sh) requires root on the
server and is intentionally kept as a CLI-only operation.
- public/admin/logs.php: new page tailing nginx error/access + PHP-FPM logs.
Selector for log file and line count (50/100/200/500, default 100).
Lines reversed (newest first), colour-coded by severity, numbered gutter.
Graceful degradation when exec() unavailable or file unreadable (dev msg).
- templates/admin/head.php: 'Journaux' nav link added after 'Statut'.
- public/admin/status.php: remove curl_close() call deprecated in PHP 8.5
(no-op since PHP 8.0); replace with unset($ch) to silence the warning
that was leaking raw text above the page output.
New page /admin/status.php gives a real-time health dashboard:
- Services panel: nginx (systemctl), php-fpm (auto-detects versioned unit names),
site HTTP ping (curl HEAD with latency), SQLite DB (exists/writable/row count/size),
storage directory (writable, banner/cover file counts), maintenance-mode flag.
- PHP runtime panel: version, SAPI, memory_limit, upload_max_filesize, post_max_size,
max_execution_time.
- Disk usage bar for the partition containing APP_ROOT (colour-coded: green/amber/red).
- All shell calls go through safeExec() which suppresses stderr and checks exit code;
systemctl/curl unavailability degrades gracefully to 'unknown' without fatal errors.
- 'Statut' nav link added to templates/admin/head.php (active state on status.php).
- Flat purple-gradient nav bar with POSTERG/RÉPERTOIRE/À PROPOS links
- Full-width search bar with icon, bottom-border only, below nav
- Home: white bg, media card grid (thumbnail + author/title label below)
- Répertoire: 4-column index (Années/Catégories/Étudiantes/Mots-clés)
- TFE: 2-column layout (large text left, media right)
- À Propos: 2-column, large monospace text, new apropos.php page
- Admin: dark theme (#1a1a1a), purple gradient nav, bottom-border inputs
- New shared partials: templates/nav.php, templates/search-bar.php
- Rewrote all CSS: common, main, search, tfe, apropos, admin
- Rename memoire.php to tfe.php throughout codebase
- Create dedicated tfe.css with rounded header/main/footer layout
- Move metadata (orientation, AP program, finality, keywords) to header
- Move back button from header to footer
- Create shared templates/head.php for common HTML head section
- Maintain rounded borders (40px) matching main site design
- Keep purple header (#9557b5), green main (#3c856b), dark footer (#222)
- Improve content readability with centered max-width layout
- Add responsive design for mobile devices
- Footer now displays all available years horizontally with scroll
- Click on year filters thesis list to that year
- Active year highlighted in footer
- 'Tous' link to reset filter
- Filter info banner shows when year selected with reset button
- Pagination preserves year filter
- Styled with horizontal scroll, smooth scrollbar
- Tests passing ✅
- Created /templates for main site (header.php, footer.php)
- Created /templates/admin for admin section (head.php, footer.php)
- Removed /public/includes and /public/admin/inc
- Updated all references in code and docs
- Tests passing ✅
Cleaner separation: /public only contains web-accessible files (PHP entry points + assets)