diff --git a/TODO.md b/TODO.md
index 3c49f1a..95227d8 100644
--- a/TODO.md
+++ b/TODO.md
@@ -11,6 +11,9 @@ Pending tasks have been split into topic files under [`todo/`](todo/README.md):
## Recently completed (this session)
+- [x] `src/SearchController.php` — extracted all data-fetching logic from `public/search.php` into a dedicated controller class; `SearchController::create()` handles rate-limit enforcement (429 response + exit) and returns a ready instance; `handle()` sanitises GET params, runs all DB queries (`searchTheses`, `countSearchResults`, `getAvailableYears`, `getAllOrientations`, `getAllAPPrograms`, `getUsedTags`, `getPublishedAuthors`), builds the alphabetical author map, assembles OG/meta tags, and returns a flat view-variable array; `public/search.php` reduced from 285 lines to 162 lines (pure dispatcher + view template)
+
+
- [x] `admin/system.php` + `assets/js/system.js` + `assets/css/system.css` — extracted the large `$extraJsInline` heredoc (≈130 lines) into a static `public/assets/js/system.js` loaded via `$extraJs`; replaced 4 inline `style=` attributes with named CSS modifier classes (`srv-section-title--compact`, `srv-section-title--sub`, `php-grid--flush`, `log-toolbar label` rule); only the dynamic `--disk-pct`/`--disk-color` CSS custom properties remain inline because they carry PHP runtime values
diff --git a/public/search.php b/public/search.php
index 60e0bed..e1b3a88 100644
--- a/public/search.php
+++ b/public/search.php
@@ -1,116 +1,12 @@
check()) {
- http_response_code(429);
- header('Retry-After: ' . $rateLimit->getResetTime());
- $retrySeconds = (int)$rateLimit->getResetTime();
- echo <<
-
-
-
-
- Trop de requêtes – Posterg
-
-
-
-
-
POSTERG
-
Trop de requêtes
-
Vous avez effectué trop de recherches en peu de temps.
- Réessayez dans {$retrySeconds} secondes.
-
-
-
-HTML;
- exit;
-}
-$rateLimit->sendHeaders();
-if (rand(1, 100) === 1) $rateLimit->cleanup();
+// Build controller (performs rate-limit check; exits with HTTP 429 if exceeded)
+$ctrl = SearchController::create();
-// Collect search/filter params
-$searchParams = [];
-if (!empty($_GET['query'])) $searchParams['query'] = trim($_GET['query']);
-if (!empty($_GET['year'])) $searchParams['year'] = intval($_GET['year']);
-if (!empty($_GET['orientation'])) $searchParams['orientation'] = $_GET['orientation'];
-if (!empty($_GET['ap_program'])) $searchParams['ap_program'] = $_GET['ap_program'];
-if (!empty($_GET['keyword'])) $searchParams['keyword'] = $_GET['keyword'];
-
-$hasSearch = !empty($searchParams);
-
-$page = isset($_GET['page']) ? intval($_GET['page']) : 1;
-$itemsPerPage = 30;
-$validationError = null;
-
-try {
- $db = Database::getInstance();
- $offset = ($page - 1) * $itemsPerPage;
-
- if ($hasSearch) {
- $results = $db->searchTheses($searchParams, $itemsPerPage, $offset);
- $totalItems = $db->countSearchResults($searchParams);
- $totalPages = ceil($totalItems / $itemsPerPage);
- } else {
- $results = [];
- $totalItems = 0;
- $totalPages = 0;
- }
-
- $years = $db->getAvailableYears();
- $orientations = $db->getAllOrientations();
- $apPrograms = $db->getAllAPPrograms();
- $keywords = $db->getUsedTags();
- // Fetch id+authors only — lean query bypassing the fat v_theses_public view
- $students = $db->getPublishedAuthors();
-} catch (InvalidArgumentException $e) {
- $validationError = $e->getMessage();
- $results = []; $totalItems = 0; $totalPages = 0;
- $years = []; $orientations = []; $apPrograms = []; $keywords = []; $students = [];
-} catch (Exception $e) {
- error_log("Search error: " . $e->getMessage());
- $validationError = "Une erreur est survenue.";
- $results = []; $totalItems = 0; $totalPages = 0;
- $years = []; $orientations = []; $apPrograms = []; $keywords = []; $students = [];
-}
-
-$currentNav = 'repertoire';
-$searchBarValue = $_GET['query'] ?? '';
-$pageTitle = 'Répertoire – Posterg';
-$metaDescription = 'Parcourez le répertoire des mémoires de fin d\'études (TFE) de l\'erg – École de Recherches Graphiques de Bruxelles. Recherche par année, orientation, atelier et mots-clés.';
-$ogTags = [
- 'type' => 'website',
- 'title' => $pageTitle,
- 'description' => $metaDescription,
- 'url' => 'https://posterg.erg.be/search.php',
- 'site_name' => 'Posterg – ERG',
-];
-$extraCss = ['/assets/css/search.css'];
-$bodyClass = 'search-body';
+// Collect all view variables
+extract($ctrl->handle());
?>
@@ -179,11 +75,7 @@ $bodyClass = 'search-body';
- '']);
- include APP_ROOT . '/templates/partials/pagination.php';
- ?>
+
Aucun résultat pour cette recherche.
@@ -241,21 +133,6 @@ $bodyClass = 'search-body';
Étudiantes
-
$id): ?>
-
diff --git a/src/SearchController.php b/src/SearchController.php
new file mode 100644
index 0000000..232f143
--- /dev/null
+++ b/src/SearchController.php
@@ -0,0 +1,257 @@
+db = $db;
+ $this->rateLimit = $rateLimit;
+ }
+
+ // ── Factory ───────────────────────────────────────────────────────────────
+
+ /**
+ * Convenience factory: builds dependencies, checks rate limit (sends 429
+ * and exits if exceeded), then returns a ready-to-use controller instance.
+ */
+ public static function create(): self
+ {
+ require_once APP_ROOT . '/src/Database.php';
+ require_once APP_ROOT . '/src/RateLimit.php';
+
+ $rateLimit = new RateLimit(self::RATE_LIMIT_MAX, self::RATE_LIMIT_WINDOW);
+
+ if (!$rateLimit->check()) {
+ self::sendRateLimitResponse($rateLimit);
+ }
+
+ $rateLimit->sendHeaders();
+
+ // Probabilistic cleanup (1-in-100 requests) to prune stale entries
+ if (rand(1, 100) === 1) {
+ $rateLimit->cleanup();
+ }
+
+ return new self(Database::getInstance(), $rateLimit);
+ }
+
+ // ── Main entry point ─────────────────────────────────────────────────────
+
+ /**
+ * Process the current request and return all variables needed by the view.
+ *
+ * @return array
+ */
+ public function handle(): array
+ {
+ $searchParams = $this->collectSearchParams();
+ $hasSearch = !empty($searchParams);
+
+ $page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
+ $offset = ($page - 1) * self::ITEMS_PER_PAGE;
+ $validationError = null;
+
+ $results = [];
+ $totalItems = 0;
+ $totalPages = 0;
+ $years = [];
+ $orientations = [];
+ $apPrograms = [];
+ $keywords = [];
+ $students = [];
+
+ try {
+ if ($hasSearch) {
+ $results = $this->db->searchTheses($searchParams, self::ITEMS_PER_PAGE, $offset);
+ $totalItems = $this->db->countSearchResults($searchParams);
+ $totalPages = (int) ceil($totalItems / self::ITEMS_PER_PAGE);
+ }
+
+ $years = $this->db->getAvailableYears();
+ $orientations = $this->db->getAllOrientations();
+ $apPrograms = $this->db->getAllAPPrograms();
+ $keywords = $this->db->getUsedTags();
+ // Fetch id+authors only — lean query bypassing the fat v_theses_public view
+ $students = $this->db->getPublishedAuthors();
+
+ } catch (InvalidArgumentException $e) {
+ $validationError = $e->getMessage();
+ } catch (Exception $e) {
+ error_log('SearchController: ' . $e->getMessage());
+ $validationError = 'Une erreur est survenue.';
+ }
+
+ // Build the author index map (répertoire index view)
+ $authorMap = $this->buildAuthorMap($students);
+
+ // Preserve all active search/filter params, strip 'page' (pagination partial adds it)
+ $baseParams = array_diff_key($_GET, ['page' => '']);
+
+ return [
+ // Search state
+ 'searchParams' => $searchParams,
+ 'hasSearch' => $hasSearch,
+ 'page' => $page,
+ 'totalItems' => $totalItems,
+ 'totalPages' => $totalPages,
+ 'results' => $results,
+ 'validationError' => $validationError,
+ 'baseParams' => $baseParams,
+
+ // Filter / index data
+ 'years' => $years,
+ 'orientations' => $orientations,
+ 'apPrograms' => $apPrograms,
+ 'keywords' => $keywords,
+ 'authorMap' => $authorMap,
+
+ // Page meta
+ 'searchBarValue' => $_GET['query'] ?? '',
+ 'pageTitle' => 'Répertoire – Posterg',
+ 'metaDescription' => 'Parcourez le répertoire des mémoires de fin d\'études (TFE) de l\'erg – École de Recherches Graphiques de Bruxelles. Recherche par année, orientation, atelier et mots-clés.',
+ 'ogTags' => [
+ 'type' => 'website',
+ 'title' => 'Répertoire – Posterg',
+ 'description' => 'Parcourez le répertoire des mémoires de fin d\'études (TFE) de l\'erg – École de Recherches Graphiques de Bruxelles. Recherche par année, orientation, atelier et mots-clés.',
+ 'url' => 'https://posterg.erg.be/search.php',
+ 'site_name' => 'Posterg – ERG',
+ ],
+ 'currentNav' => 'repertoire',
+ 'extraCss' => ['/assets/css/search.css'],
+ 'bodyClass' => 'search-body',
+ ];
+ }
+
+ // ── Private helpers ───────────────────────────────────────────────────────
+
+ /**
+ * Sanitise and collect valid search parameters from $_GET.
+ *
+ * @return array
+ */
+ private function collectSearchParams(): array
+ {
+ $params = [];
+
+ if (!empty($_GET['query'])) {
+ $params['query'] = trim((string) $_GET['query']);
+ }
+ if (!empty($_GET['year'])) {
+ $params['year'] = (int) $_GET['year'];
+ }
+ if (!empty($_GET['orientation'])) {
+ $params['orientation'] = (string) $_GET['orientation'];
+ }
+ if (!empty($_GET['ap_program'])) {
+ $params['ap_program'] = (string) $_GET['ap_program'];
+ }
+ if (!empty($_GET['keyword'])) {
+ $params['keyword'] = (string) $_GET['keyword'];
+ }
+
+ return $params;
+ }
+
+ /**
+ * Build an alphabetically-sorted author → thesis-id map from the
+ * published-authors list. Each author name maps to their first thesis id.
+ *
+ * @param array $students
+ * @return array
+ */
+ private function buildAuthorMap(array $students): array
+ {
+ $map = [];
+
+ foreach ($students as $s) {
+ if (empty($s['authors'])) {
+ continue;
+ }
+ foreach (explode(',', $s['authors']) as $name) {
+ $name = trim($name);
+ if ($name !== '' && !isset($map[$name])) {
+ $map[$name] = (int) $s['id'];
+ }
+ }
+ }
+
+ ksort($map);
+
+ return $map;
+ }
+
+ // ── Rate-limit response ───────────────────────────────────────────────────
+
+ /**
+ * Send a 429 response and exit. Never returns.
+ */
+ private static function sendRateLimitResponse(RateLimit $rateLimit): never
+ {
+ http_response_code(429);
+ header('Retry-After: ' . $rateLimit->getResetTime());
+ $retrySeconds = (int) $rateLimit->getResetTime();
+
+ echo <<
+
+
+
+
+ Trop de requêtes – Posterg
+
+
+
+
+
POSTERG
+
Trop de requêtes
+
Vous avez effectué trop de recherches en peu de temps.
+ Réessayez dans {$retrySeconds} secondes.
+
+
+
+HTML;
+ exit;
+ }
+}
diff --git a/todo/02-php-components.md b/todo/02-php-components.md
index 9802210..6931131 100644
--- a/todo/02-php-components.md
+++ b/todo/02-php-components.md
@@ -16,7 +16,7 @@
## Controller Extraction (In Progress)
-- [ ] Extract `SearchController` — most complex public page
+- [x] Extract `SearchController` — `src/SearchController.php`; rate-limiting, param sanitisation, DB queries, OG meta, and author-map construction moved out of `public/search.php`; entry point is now a 6-line dispatcher (`create()` + `handle()` + `extract()`); view template unchanged
- [ ] Extract `SystemController` — biggest single-file win, 500→8 lines
- [ ] Extract `ThesisEditController` — merges `edit.php` + `actions/edit.php`, deduplicates jury fieldset
- [ ] Extract remaining controllers one by one