script-src 'self' 'unsafe-inline' added to admin Content-Security-Policy.
default-src 'self' was blocking OverType editor init block and
the dev live-reload poller. Admin section is auth-gated so
unsafe-inline is acceptable.
Silence mkdir() with @ operator; guard file_put_contents with
is_writable() check. When storage/cache/rate_limit is not writable
by php-fpm, requests are allowed through instead of throwing
warnings that flood the nginx error log.
The SVG icon in the admin nav's public-site link had two inline styles:
style="vertical-align:middle;margin-right:0.4em"
Moved to a new CSS rule:
.admin-body header nav > a svg { vertical-align: middle; margin-right: 0.4em; }
templates/header.php now contains zero style= attributes.
The only remaining inline styles project-wide are:
- dynamic gradient (hsl computed from $item['id']) in public/index.php — legitimately dynamic
- --disk-pct/--disk-color custom properties in system.php — carry PHP runtime values
admin/thanks.php:
- <div style="margin-top:1.5rem;display:flex;gap:.75rem;flex-wrap:wrap;"> → class="admin-action-bar"
- <p style="color:var(--text-secondary);"> → class="admin-muted"
admin/pages.php:
- Éditer button style="font-size:.8rem;padding:.3rem .75rem;" → class="admin-btn admin-btn--sm"
admin.css (Thesis info sections block):
- Added .admin-action-bar { margin-top:1.5rem; display:flex; gap:0.75rem; flex-wrap:wrap }
- Added .admin-muted { color: var(--text-secondary) }
The only remaining inline style in any admin PHP file is the dynamic
--disk-pct/--disk-color custom properties on the disk bar in system.php,
which carry PHP runtime values and cannot be moved to static CSS.
Scope: variables.css, search.css, todo/04-accessibility.md
- variables.css: add @media (prefers-color-scheme: dark) block scoped to
body:not(.admin-body); overrides all semantic tokens with dark equivalents:
--bg-* (#111→#333 range), --text-* (#eee/aaa/777),
--border-* (#333/#444), --accent-primary lightened to #b87fd4
(4.5:1 contrast on #111 background), --accent-secondary stays #9557b5,
--accent-foreground flipped to #111111 for dark buttons,
--accent-muted adjusted to rgba(184,127,212,0.15),
status colours muted for dark (success #4db886, error #e05555,
warning #d4a830); new --search-error-{bg,border,color} tokens added
to :root (light: #fff0f0/#c00) and overridden in dark (#2a1515/#e05555)
- search.css: replace three hardcoded hex values in .search-error rule
with var(--search-error-bg/border/color) so dark mode applies cleanly
- Admin pages are entirely unaffected: .admin-body body class is excluded
from the dark-mode selector; system.css already has its own dark palette
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.