Consolidate action handlers into controller methods (todo/02-php-components.md).
src/ThesisCreateController.php (new, 435 lines)
Mirrors ThesisEditController for the add-thesis flow.
make() — factory; instantiates Database via new Database()
loadFormData() — returns all lookup tables needed by admin/add.php
(orientations, apPrograms, finalityTypes, languages,
formatTypes, licenseTypes)
submit(post, files) — full new-thesis creation pipeline:
1. validateAndSanitise() — trims/strips HTML, validates required fields,
year range, orientation/ap/finality IDs, language selection, max-10
keywords, URL format; throws named Exception on failure
2. findOrCreateAuthor() — reuses existing DB method
3. Transaction: createThesis + setThesisJury + setThesisLanguages +
setThesisFormats + setThesisTags; rolls back on any failure
4. File uploads outside transaction: cover image (JPG/PNG only, stored in
storage/covers/), banner via handleBannerUpload(), thesis files
(PDF/JPG/PNG/MP4/ZIP/VTT, stored in storage/theses/YEAR/IDENT/,
file_type auto-detected: caption/annex/main/other)
autofocusFieldForError() — static; maps exception messages to field names
for WCAG 3.3.1 autofocus on re-render (same contract as
ThesisEditController::autofocusFieldForError)
admin/actions/formulaire.php 346 → 45 lines
Now: bootstrap + CSRF guard + ThesisCreateController::make()->submit() +
flash/redirect on error. All validation, DB logic, and file handling removed.
admin/add.php
Lookup-table block (new Database() + 6 individual DB calls) replaced with
ThesisCreateController::make()->loadFormData() + extract().
src/Database.php — two new methods added
setPublished(int , bool ): void
UPDATE theses SET is_published = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
bulkSetPublished(int[] , bool ): void
Same but with an IN (...) clause for multiple IDs
admin/actions/publish.php 100 → 65 lines
Raw SQL (->prepare('UPDATE theses SET is_published = ?...')) replaced
with ->setPublished() / ->bulkSetPublished(). No raw PDO calls remain
in any action handler file.
Move all data-fetching and view-variable assembly out of public/index.php
into a new src/HomeController.php, following the same pattern as
SearchController, TfeController, SystemController, and ThesisEditController.
HomeController::create() builds the Database singleton dependency.
HomeController::handle() encapsulates:
- GET param parsing (page, year) with safe type coercion
- Display-mode detection: default random-latest view / year-filtered /
paginated-all theses
- All DB calls: getLatestPublishedYear, getLatestYearTheses, searchTheses,
countSearchResults, getPublishedTheses, countPublishedTheses,
getCoverPathsForTheses, getAvailableYears
- Batch cover-image loading for theses without a banner_path
- baseParams assembly for the pagination partial
- OG / meta tag array construction
- Graceful error handling (logs exception, returns safe empty state)
- Returns a flat array of view variables
public/index.php is now a 6-line dispatcher (require + create + handle +
extract) followed by a pure view template. Reduced from 100 to 71 lines.
All error-handling and data logic removed from the view layer entirely.
src/ThesisEditController.php (285 lines) centralises all data-fetching and
mutation logic for the thesis-edit workflow:
load(int $thesisId): array
Fetches the thesis row, current language/format/jury selections, and all
lookup tables (orientations, AP programmes, finality types, languages,
formats, licences, access types) in one call. Returns a flat view-variable
array that the dispatcher extracts directly.
save(int $thesisId, array $post, array $files): void
Runs the full edit inside a transaction: thesis metadata, authors, jury,
languages, formats, tags. Banner upload/removal is handled outside the
transaction (filesystem op). Rolls back and re-throws on any failure.
static autofocusFieldForError(string $msg): ?string
Centralises the WCAG 3.3.1 exception-message → field-name mapping that
was previously duplicated inline in actions/edit.php.
Dispatcher changes:
admin/edit.php 191 → 162 lines (pure view + ThesisEditController::create() + load())
actions/edit.php 153 → 53 lines (CSRF guard + ThesisEditController::save() call)
Follows the same pattern as SearchController and SystemController.
Move all data-fetching and request logic out of the 285-line search page
into src/SearchController.php:
- SearchController::create() — static factory; builds RateLimit + Database
dependencies, sends HTTP 429 (and exits) if rate limit is exceeded,
runs probabilistic cleanup, returns ready instance
- SearchController::handle() — sanitises GET params (query/year/orientation/
ap_program/keyword), runs all DB queries (searchTheses, countSearchResults,
getAvailableYears, getAllOrientations, getAllAPPrograms, getUsedTags,
getPublishedAuthors), builds alphabetical author→id map, assembles
OG/meta tags, returns a flat array of view variables
- Rate-limit 429 HTML response moved into private sendRateLimitResponse()
public/search.php is now a 6-line dispatcher:
require SearchController; extract(SearchController::create()->handle());
followed by the unchanged view template (162 lines total, was 285).
The view template is byte-for-byte equivalent: same HTML, same variable
names, same pagination partial include.
Move the ~130-line $extraJsInline heredoc from admin/system.php into a
static file public/assets/js/system.js, loaded via $extraJs so the
template footer emits a normal <script src=…>.
Replace 4 inline style= attributes with named CSS modifier classes in
system.css:
- style="margin:0;border:none;padding:0" on .srv-section-title
→ .srv-section-title--compact
- style="margin-bottom:.75rem" on sub-heading <h3>
→ .srv-section-title--sub
- style="margin-bottom:0" on .php-grid
→ .php-grid--flush
- style="font-size:.84rem;color:var(--text-secondary)" on <label>
→ .log-toolbar label rule in system.css
The one remaining inline style (--disk-pct / --disk-color CSS custom
properties on .disk-bar) is intentionally kept: it carries PHP runtime
values that cannot be expressed in a static stylesheet.
App::consumeFlash() had 18-line legacy fallback chains reading from seven old
session keys (error, admin_error, edit_error, form_error, success,
admin_success, edit_success) that were written by no code in the codebase.
All action handlers have used App::flash() -> _flash_error / _flash_success
since the App class was introduced. Removed the dead fallbacks; consumeFlash()
is now 4 lines.
admin/import.php was the last admin template with inline style= attributes.
Extracted four of them to named CSS classes in admin.css:
- admin-error-list — error <ul> spacing (was style="margin:.5rem 0 0;padding-left:1.2rem")
- admin-file-hint — <small> display + margin (was style="margin-top:.5rem")
- admin-import-results — results panel margin (was style="margin-top:2rem")
- admin-import-results__title — <h2> typography (was multi-property inline style)
Closes the 'unify flash message keys' item in todo/02-php-components.md and
the import.php inline style item in todo/01-css-semantic-refactor.md.
Add App::flashAutofocus(fieldName) and consumeAutofocus() to the thin App
helper so action handlers can identify which field caused a validation error
and the form page can move browser focus directly to it on reload.
Changes:
- src/App.php — flashAutofocus() stores field name in _flash_autofocus
session key; consumeAutofocus() drains it and returns the name (or null)
- actions/formulaire.php — catch block maps exception messages to field
names (auteurice, titre, synopsis, année, orientation, ap, finality,
languages, tag, lien) and calls App::flashAutofocus()
- actions/edit.php — catch block maps common edit errors to field names
and calls App::flashAutofocus()
- add.php — consumes the hint via App::consumeAutofocus() into
$autofocusField; withAutofocus() helper merges autofocus=>true into
$attrs for every field include; synopsis textarea gets inline autofocus
- edit.php — same pattern with inline ternary merges and textarea autofocus
- templates/partials/form/text-field.php — $attrs loop now emits bare
attribute names (no ="...") when value === true, supporting autofocus,
disabled, readonly etc. without special-casing
- templates/partials/form/select-field.php — same boolean-attr support
added; $attrs variable initialised to [] when caller omits it
Closes WCAG 3.3.1 autofocus item in todo/04-accessibility.md.
Names, roles, emails, and credits on the À propos page were hardcoded
directly in apropos.php HTML. To update a contact meant editing a
template file — risky for non-developers and easy to introduce a typo
or broken mailto link.
Changes:
- config/apropos.php: new config array with erg_url, contacts[] (name,
role, email per person) and credits[] (label/value pairs); follows
the same pattern as config/admin_credentials.php
- public/apropos.php: loads config via require; aside section now loops
over $apropos['contacts'] and $apropos['credits'] with htmlspecialchars
throughout; hardcoded HTML strings removed entirely
Also audited todo/02-php-components.md and marked 8 stale items as done:
all 5 form field partials were already implemented and in use, the
flash-message consolidation was already handled by App::consumeFlash(),
and the RateLimit cache dir was already at storage/cache/rate_limit
(excluded from deploy rsync).
Problem: <video> elements on tfe.php had no <track kind="captions"> element,
violating WCAG 4.1.2 (name, role, value) for video content.
Changes:
- public/tfe.php: collect all text/vtt files from the thesis file list before
rendering; skip standalone rendering of .vtt entries; for each MP4 emit a
<track kind="captions" srclang="fr" label="Sous-titres" default> pointing
to the N-th VTT file (N-th video paired with N-th caption in document order)
- public/media.php: add text/vtt to allowed MIME list; normalise finfo
text/plain -> text/vtt for .vtt files; add vtt branch to cache/header
block (Content-Type: text/vtt; charset=utf-8, 1-day cache)
- public/admin/actions/formulaire.php: allow .vtt uploads (text/vtt MIME,
vtt extension); normalise text/plain finfo result; set file_type='caption'
for VTT files so they are distinguishable from other thesis files
- public/admin/add.php: extend files field accept attr to include .vtt;
update hint text to document the VTT sidecar convention
VTT files uploaded under theses/ inherit the same access_type visibility
gate in media.php as all other thesis content (403 for access_type_id=3).
- admin/edit.php: remove mb_strimwidth(60) truncation from access_type
<select> option labels; full 'name — description' text is now the
accessible name so screen readers get unambiguous option text (WCAG 4.1.2)
- public/assets/favicon.svg: new public favicon — brand-purple (#9557b5)
rounded square with white 'P' lettermark; distinct from admin_favicon.svg
(archive-restore Lucide icon in #c104fc) which is admin-only
- templates/head.php: favicon <link> now conditionally serves favicon.svg
(public pages) or admin_favicon.svg (admin pages) based on $isAdmin;
closes the open favicon task in todo/01-css-semantic-refactor.md
- todo/04-accessibility.md: mark WCAG 3.1.1 lang audit and WCAG 4.1.2
select truncation items as done
- todo/01-css-semantic-refactor.md: mark favicon task as done
3.3.2 Labels or instructions
- Replace bare <label>Lecteur·ices :</label> (no 'for', no associated control)
with <fieldset class="admin-jury-lecteurs"><legend>Lecteur·ices</legend>
giving AT a proper programmatic label for the entire lecteur group
4.1.2 Name, role, value — Externe checkboxes lacked group context
- Add aria-label="Promoteur·ice — externe" on the promoteur Externe checkbox
- Add aria-label="Lecteur·ice N — nom" on every lecteur name input
- Add aria-label="Lecteur·ice N — externe" on every lecteur Externe checkbox
- All three attributes added to both PHP-rendered rows and the addJuryRow() JS
that builds new rows dynamically
2.1.1 Keyboard — remove buttons already had aria-label; verified and updated
label text to "Supprimer le lecteur·ice N" (consistent with new numbering)
CSS (admin.css)
- Add .admin-body fieldset fieldset.admin-jury-lecteurs rule: removes
border/padding/background from the nested fieldset so it reads as a
sub-group inside the outer jury fieldset, not a double-bordered card
Audit (no code change)
- WCAG 1.4.4: all font-size values use rem — no px text sizing anywhere
- WCAG 1.4.12: only overflow:hidden on media containers and .sr-only utility;
no essential text content is clipped by text-spacing overrides
- WCAG 4.1.2 bulk JS: result is a redirect to flash-messages.php which already
emits role="alert"/role="status" — no additional JS announcement needed
Add <span class="sr-only">, YEAR</span> to each thesis card <p> in
public/index.php. Screen readers now read "Author – Title, 2024" instead
of bare "Author – Title", so two theses sharing the same title produce
distinct accessible names (WCAG 2.4.4 Link Purpose — In Context).
Also audit and close WCAG 2.4.3: the tfe.php back link (<a class="tfe-back-link">
← Retour</a>) is already the first child of <header class="tfe-left">
in DOM order, preceding <h1 class="tfe-title">. No code change needed;
TODO item marked done.
- Updated 6 admin templates: add.php, edit.php, login.php, account.php,
import.php, pages-edit.php — replaced <div class="admin-submit-wrap">
with <div class="admin-form-footer">
- Updated 8 CSS selectors in admin.css:
- .admin-form-footer { margin-top/padding-top } (was .admin-submit-wrap)
- .admin-form > div:not(.admin-form-footer) grid exclusion guard (×3)
- .admin-login-box .admin-form > div:not(.admin-form-footer) overrides (×2)
- .admin-login-box .admin-form-footer compact spacing override
- No visual change; purely a semantic rename to a descriptive class name
- Also marked status-badge.php partial and WCAG 1.3.1 badge tasks as
already-done in todo/02-php-components.md and todo/04-accessibility.md
(partial + CSS were fully implemented but todo had not been updated)
WCAG 1.4.1 — Active nav link had no non-colour indicator in the admin panel.
Public nav already had border-bottom via common.css; admin nav had nothing.
admin.css:
- Add `[aria-current="page"]` rule on admin nav links:
border-bottom: 2px solid currentColor; padding-bottom: 1px
This gives a visible underline as a non-colour signal for the active page.
- Fix `--admin-purple` undefined CSS variable in pagination button hover.
The variable was referenced but never defined in variables.css (which was
refactored to use --accent-primary / --accent-secondary). Replaced both
border-color and color usages with var(--accent-primary) (#9557b5 — same
value), restoring the intended purple hover tint on pagination buttons.
todo/01-css-semantic-refactor.md:
- Audited ~15 pending CSS/HTML tasks; all were already implemented.
Marked as done: .admin-main, .admin-page-title, .admin-form-row,
.admin-label, .admin-input/select/textarea, .admin-table, .admin-fieldset,
tfe.css class replacements, search.css h2 selector, admin-alert replacement,
login.php/edit.php inline style removal, form partial hints (<small>).
todo/04-accessibility.md:
- Marked WCAG 1.4.1 admin nav and --admin-purple audit items as completed.
- Add Database::getThesesListCount(array $filters) — runs the same WHERE
clauses as getThesesList() but with COUNT(DISTINCT t.id); used to compute
total pages without loading all rows.
- Extend Database::getThesesList() with $limit/$offset parameters; when
$limit > 0 appends LIMIT/OFFSET and re-binds positional params individually
to avoid the PDO mixed-style restriction.
- Fix getThesesList() SELECT: add LEFT JOIN access_types + at.name as
access_type — the column was referenced in the template but never fetched.
- Wire admin/index.php: read ?page=, compute $totalPages/$offset, pass
$perPage=25 + $offset to getThesesList(); include pagination.php partial
below the table with filter-preserving $baseParams.
- Add result-count line (<p class="admin-list-meta">) showing "X–Y sur Z TFE"
when multiple pages exist.
- Add .admin-body .pagination-wrap / .pagination-btn / .pagination-info styles
to admin.css (scoped to .admin-body to avoid colliding with public pages).
The file had accumulated severe corruption in its lower half (garbled
selector text, variable names spliced into property values, orphaned
declarations, broken nesting) alongside hardcoded hex colours throughout.
Rewrote the entire file cleanly:
- Every colour is now a var() referencing a token defined in variables.css:
--accent-primary/secondary/foreground, --accent-blue/green/yellow/red,
--bg-secondary/tertiary, --border-primary, --text-primary/secondary/tertiary,
--error, --warning, --success, --accent-muted.
- Zero raw hex values remain in admin.css.
- Removed the corrupted/dead CSS from the bottom half and reconstructed
all selectors from what the templates actually use (audited via grep).
- Fixed structural issues: broken border shorthand, nested rules that
were not valid CSS, orphaned declaration blocks.
- New/restored rules: .admin-maintenance-bar (was corrupted),
.status-access variants (was corrupted), .admin-section-title--danger,
.admin-danger-zone, .admin-account-status (all reconstructed cleanly).
- .admin-btn--warning and .admin-btn--danger now use var(--accent-yellow)
and var(--accent-red) instead of hardcoded dark hex values.
- .admin-btn-remove hover now uses var(--error) instead of #e55.
- .admin-btn-unpublish now uses var(--bg-secondary)/var(--text-tertiary)
instead of hardcoded grey hex values.
- select option background colours removed (browser chrome, not styleable
cross-platform).
Templates: replace 4 inline var(--admin-text-muted) with var(--text-secondary)
in index.php, thanks.php, import.php.
Add system-fragment.php — a thin authenticated endpoint that returns only
the tab-panel HTML (toolbar + meta + log/nginx-config output) for a given
?tab=&n= combination. No page shell, no status section, no DB queries.
system.php changes:
- Tab <a> elements gain data-tab= attributes used by JS to identify the
target without parsing hrefs.
- Tab panel content wrapped in <div id=sys-tab-panel data-tab= data-n=>
which JS uses as both the swap target and its own state store.
- JS rewritten: tab clicks and lines-select changes call loadPanel()
which fetch()es system-fragment.php, swaps innerHTML, updates active
tab ARIA attributes, and pushes state via history.pushState.
- Browser back/forward handled via popstate listener.
- bindPanelControls() re-wires the lines-select and copy-to-clipboard
button after every innerHTML swap (event delegation not feasible here
because log-output is replaced wholesale).
- fetch() failure falls back to window.location.href (full page load).
- Tabs without JS still work: <a href> links go to system.php?tab=…
as before.
system-fragment.php:
- Requires AdminAuth::isAuthenticated(); returns 403 on failure.
- Validates tab and n params against the same whitelist as system.php.
- All helper functions namespaced with frag_ prefix to avoid redeclaration
if PHP ever includes both files in the same process.
- Renders identical HTML to the corresponding section in system.php.
system.css:
- #sys-tab-panel gets min-height:8rem and position:relative to prevent
layout jump during fetch.
- .sys-panel-loading: opacity 0.4 + pointer-events:none + subtle
diagonal-stripe ::after overlay with shimmer animation.
Status (services, PHP env, disk) is now always visible above the log/config
tab bar rather than being one of the tab targets:
- Status section rendered unconditionally above <nav class="sys-tabs">.
- Services grid, PHP info grid and disk bar grouped inside a collapsible
<section> with a header row containing the cache-freshness badge and a
toggle button (▲ Réduire / ▼ Développer).
- Collapse state persisted in localStorage so the preference survives
page reloads (e.g. when switching log tabs).
- Tab bar now only contains the three log tabs + nginx config; the 'Statut'
tab is removed. Legacy ?tab=status URLs fall through to nginx_access.
- PHP/disk sub-sections laid out in a 2-col grid inside the status panel;
responsive single-col below 700px.
- system.css: new .sys-status-section / .sys-status-header /
.sys-status-toggle / .sys-status-meta rules added.
- aria-current="page" added to active tab links.
- todo/03-system-cache.md: all items marked done; notes added explaining
why log caching was deliberately omitted.