mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-05-06 19:19:19 +02:00
répertoire: rename search.php, 6-column layout, HTMX filter, faded entries disabled, URL-shareable
This commit is contained in:
137
src/Database.php
137
src/Database.php
@@ -493,6 +493,143 @@ class Database {
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute répertoire filter data.
|
||||
*
|
||||
* Given a set of active filters (each an array of values, combined as AND
|
||||
* across filter types, OR within each filter type), returns:
|
||||
* - matched_ids : int[] thesis IDs matching ALL active filters
|
||||
* - years : array all years with matched flag
|
||||
* - ap_programs : array all AP programs with matched flag
|
||||
* - orientations : array all orientations with matched flag
|
||||
* - finality_types: array all finality types with matched flag
|
||||
* - keywords : array all used keywords with matched flag
|
||||
* - students : array [id, authors] rows for matched theses only
|
||||
*
|
||||
* For each column, "matched" means the value appears in at least one thesis
|
||||
* that satisfies all the OTHER active filters (excluding that column's own
|
||||
* filter when computing its own relevance).
|
||||
*
|
||||
* @param array{years:int[], ap:string[], or:string[], fi:string[], kw:string[]} $filters
|
||||
*/
|
||||
public function getRepertoireFilterData(array $filters): array {
|
||||
$baseJoins = "
|
||||
FROM theses t
|
||||
LEFT JOIN orientations o ON t.orientation_id = o.id
|
||||
LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id
|
||||
LEFT JOIN finality_types ft ON t.finality_id = ft.id
|
||||
";
|
||||
|
||||
// Build WHERE + bindings excluding one dimension (for that column's own relevance)
|
||||
$buildWhere = function(string $exclude) use ($filters): array {
|
||||
$conditions = ['t.is_published = 1'];
|
||||
$bindings = [];
|
||||
|
||||
if ($exclude !== 'years' && !empty($filters['years'])) {
|
||||
$ph = implode(',', array_fill(0, count($filters['years']), '?'));
|
||||
$conditions[] = "t.year IN ($ph)";
|
||||
foreach ($filters['years'] as $v) $bindings[] = (int)$v;
|
||||
}
|
||||
if ($exclude !== 'ap' && !empty($filters['ap'])) {
|
||||
$ph = implode(',', array_fill(0, count($filters['ap']), '?'));
|
||||
$conditions[] = "ap.name IN ($ph)";
|
||||
foreach ($filters['ap'] as $v) $bindings[] = (string)$v;
|
||||
}
|
||||
if ($exclude !== 'or' && !empty($filters['or'])) {
|
||||
$ph = implode(',', array_fill(0, count($filters['or']), '?'));
|
||||
$conditions[] = "o.name IN ($ph)";
|
||||
foreach ($filters['or'] as $v) $bindings[] = (string)$v;
|
||||
}
|
||||
if ($exclude !== 'fi' && !empty($filters['fi'])) {
|
||||
$ph = implode(',', array_fill(0, count($filters['fi']), '?'));
|
||||
$conditions[] = "ft.name IN ($ph)";
|
||||
foreach ($filters['fi'] as $v) $bindings[] = (string)$v;
|
||||
}
|
||||
if ($exclude !== 'kw' && !empty($filters['kw'])) {
|
||||
foreach ($filters['kw'] as $kv) {
|
||||
$conditions[] = 'EXISTS (SELECT 1 FROM thesis_tags tt2 JOIN tags tg2 ON tg2.id=tt2.tag_id WHERE tt2.thesis_id=t.id AND tg2.name=?)';
|
||||
$bindings[] = (string)$kv;
|
||||
}
|
||||
}
|
||||
return [implode(' AND ', $conditions), $bindings];
|
||||
};
|
||||
|
||||
$exec = function(string $sql, array $b): array {
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute($b);
|
||||
return $stmt->fetchAll();
|
||||
};
|
||||
|
||||
// Full intersection — matched thesis IDs
|
||||
[$wAll, $bAll] = $buildWhere('__none__');
|
||||
$matchedIds = array_column($exec("SELECT t.id $baseJoins WHERE $wAll", $bAll), 'id');
|
||||
|
||||
// Years
|
||||
[$w, $b] = $buildWhere('years');
|
||||
$matchedYears = array_column($exec("SELECT DISTINCT t.year $baseJoins WHERE $w ORDER BY t.year DESC", $b), 'year');
|
||||
$allYears = array_column($exec("SELECT DISTINCT year FROM theses WHERE is_published=1 ORDER BY year DESC", []), 'year');
|
||||
$yearsOut = array_map(fn($y) => ['value' => $y, 'matched' => in_array($y, $matchedYears, true)], $allYears);
|
||||
|
||||
// AP programs
|
||||
[$w, $b] = $buildWhere('ap');
|
||||
$matchedAp = array_column($exec("SELECT DISTINCT ap.name $baseJoins WHERE $w AND ap.name IS NOT NULL ORDER BY ap.name", $b), 'name');
|
||||
$allAp = array_column($exec("SELECT name FROM ap_programs ORDER BY name", []), 'name');
|
||||
$apOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedAp, true)], $allAp);
|
||||
|
||||
// Orientations
|
||||
[$w, $b] = $buildWhere('or');
|
||||
$matchedOr = array_column($exec("SELECT DISTINCT o.name $baseJoins WHERE $w AND o.name IS NOT NULL ORDER BY o.name", $b), 'name');
|
||||
$allOr = array_column($exec("SELECT name FROM orientations ORDER BY name", []), 'name');
|
||||
$orOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedOr, true)], $allOr);
|
||||
|
||||
// Finality types
|
||||
[$w, $b] = $buildWhere('fi');
|
||||
$matchedFi = array_column($exec("SELECT DISTINCT ft.name $baseJoins WHERE $w AND ft.name IS NOT NULL ORDER BY ft.name", $b), 'name');
|
||||
$allFi = array_column($exec("SELECT name FROM finality_types ORDER BY name", []), 'name');
|
||||
$fiOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedFi, true)], $allFi);
|
||||
|
||||
// Keywords
|
||||
[$w, $b] = $buildWhere('kw');
|
||||
$matchedKw = array_column($exec(
|
||||
"SELECT DISTINCT tg.name $baseJoins
|
||||
JOIN thesis_tags tt ON tt.thesis_id = t.id
|
||||
JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE $w ORDER BY tg.name", $b), 'name');
|
||||
$allKw = array_column($exec(
|
||||
"SELECT DISTINCT tg.name FROM tags tg
|
||||
JOIN thesis_tags tt ON tg.id = tt.tag_id
|
||||
JOIN theses th ON tt.thesis_id = th.id
|
||||
WHERE th.is_published = 1 ORDER BY tg.name", []), 'name');
|
||||
$kwOut = array_map(fn($n) => ['value' => $n, 'matched' => in_array($n, $matchedKw, true)], $allKw);
|
||||
|
||||
// Students (output only — full intersection)
|
||||
$studentsOut = [];
|
||||
if (!empty($matchedIds)) {
|
||||
$ph = implode(',', array_fill(0, count($matchedIds), '?'));
|
||||
$studentsOut = $exec(
|
||||
"SELECT t.id,
|
||||
GROUP_CONCAT(a.name ORDER BY ta.author_order ASC) AS authors
|
||||
FROM theses t
|
||||
JOIN thesis_authors ta ON ta.thesis_id = t.id
|
||||
JOIN authors a ON a.id = ta.author_id
|
||||
WHERE t.id IN ($ph)
|
||||
GROUP BY t.id
|
||||
ORDER BY MIN(a.name) ASC",
|
||||
$matchedIds
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'matched_ids' => $matchedIds,
|
||||
'years' => $yearsOut,
|
||||
'ap_programs' => $apOut,
|
||||
'orientations' => $orOut,
|
||||
'finality_types' => $fiOut,
|
||||
'keywords' => $kwOut,
|
||||
'students' => $studentsOut,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all format types
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SearchController
|
||||
*
|
||||
* Handles all data-fetching logic for the public search / répertoire page.
|
||||
* The entry point (public/search.php) delegates to this class and receives
|
||||
* The entry point (public/repertoire.php) delegates to this class and receives
|
||||
* a plain array of view variables ready for template inclusion.
|
||||
*
|
||||
* Responsibilities:
|
||||
@@ -11,9 +11,11 @@
|
||||
* - GET parameter sanitisation and validation
|
||||
* - Database queries (search + index listings)
|
||||
* - OG / meta tag assembly
|
||||
* - HTMX partial response for repertoire filter swaps
|
||||
*
|
||||
* The class has NO output side-effects; all template rendering stays in
|
||||
* public/search.php so the view layer remains easy to inspect and modify.
|
||||
* public/repertoire.php so the view layer remains easy to inspect and modify.
|
||||
* Exception: renderRepertoirePartial() exits early for HTMX requests.
|
||||
*/
|
||||
class SearchController
|
||||
{
|
||||
@@ -66,36 +68,38 @@ class SearchController
|
||||
*/
|
||||
public function handle(): array
|
||||
{
|
||||
$isHtmx = !empty($_SERVER['HTTP_HX_REQUEST']);
|
||||
$searchParams = $this->collectSearchParams();
|
||||
$hasSearch = !empty($searchParams);
|
||||
|
||||
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
|
||||
$offset = ($page - 1) * self::ITEMS_PER_PAGE;
|
||||
$activeFilters = $this->collectFilterParams();
|
||||
|
||||
$page = isset($_GET['page']) ? max(1, (int) $_GET['page']) : 1;
|
||||
$offset = ($page - 1) * self::ITEMS_PER_PAGE;
|
||||
$validationError = null;
|
||||
|
||||
$results = [];
|
||||
$totalItems = 0;
|
||||
$totalPages = 0;
|
||||
$repData = null;
|
||||
|
||||
// For search filter dropdowns (text search mode only)
|
||||
$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();
|
||||
} else {
|
||||
// Repertoire index: compute filter data (all columns + matched flags)
|
||||
$repData = $this->db->getRepertoireFilterData($activeFilters);
|
||||
}
|
||||
|
||||
$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) {
|
||||
@@ -103,8 +107,10 @@ class SearchController
|
||||
$validationError = 'Une erreur est survenue.';
|
||||
}
|
||||
|
||||
// Build the author index map (répertoire index view)
|
||||
$authorMap = $this->buildAuthorMap($students);
|
||||
// HTMX partial: render just the index div and exit
|
||||
if ($isHtmx && !$hasSearch && $repData !== null) {
|
||||
$this->renderRepertoirePartial($repData, $activeFilters);
|
||||
}
|
||||
|
||||
// Preserve all active search/filter params, strip 'page' (pagination partial adds it)
|
||||
$baseParams = array_diff_key($_GET, ['page' => '']);
|
||||
@@ -120,22 +126,25 @@ class SearchController
|
||||
'validationError' => $validationError,
|
||||
'baseParams' => $baseParams,
|
||||
|
||||
// Filter / index data
|
||||
// Repertoire filter state
|
||||
'repData' => $repData,
|
||||
'activeFilters' => $activeFilters,
|
||||
'isHtmx' => $isHtmx,
|
||||
|
||||
// Search filter dropdowns (text search mode only)
|
||||
'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.',
|
||||
'metaDescription' => "Parcourez le répertoire des mémoires de fin d'études (TFE) de l'erg – École de Recherches Graphiques de Bruxelles.",
|
||||
'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',
|
||||
'description' => "Parcourez le répertoire des mémoires de fin d'études (TFE) de l'erg – École de Recherches Graphiques de Bruxelles.",
|
||||
'url' => 'https://posterg.erg.be/repertoire.php',
|
||||
'site_name' => 'Posterg – ERG',
|
||||
],
|
||||
'currentNav' => 'repertoire',
|
||||
@@ -147,7 +156,58 @@ class SearchController
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sanitise and collect valid search parameters from $_GET.
|
||||
* Render the repertoire index partial and exit (for HTMX swaps).
|
||||
* Never returns.
|
||||
*/
|
||||
private function renderRepertoirePartial(array $repData, array $activeFilters): never
|
||||
{
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
$isHtmx = true;
|
||||
include APP_ROOT . '/templates/partials/repertoire-index.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect and sanitise repertoire filter params from $_GET.
|
||||
* Params: fy[] (years), ap[] (AP programs), or[] (orientations),
|
||||
* fi[] (finality types), kw[] (keywords)
|
||||
*
|
||||
* @return array{years:int[], ap:string[], or:string[], fi:string[], kw:string[]}
|
||||
*/
|
||||
private function collectFilterParams(): array
|
||||
{
|
||||
$sanitiseStrings = function(mixed $raw, int $maxLen = 100): array {
|
||||
if (!is_array($raw)) return [];
|
||||
$out = [];
|
||||
foreach ($raw as $v) {
|
||||
$v = trim((string)$v);
|
||||
if ($v !== '' && mb_strlen($v) <= $maxLen) {
|
||||
$out[] = $v;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
};
|
||||
|
||||
$years = [];
|
||||
if (!empty($_GET['fy']) && is_array($_GET['fy'])) {
|
||||
foreach ($_GET['fy'] as $y) {
|
||||
$y = (int)$y;
|
||||
if ($y >= 1900 && $y <= 2100) $years[] = $y;
|
||||
}
|
||||
$years = array_values(array_unique($years));
|
||||
}
|
||||
|
||||
return [
|
||||
'years' => $years,
|
||||
'ap' => $sanitiseStrings($_GET['ap'] ?? []),
|
||||
'or' => $sanitiseStrings($_GET['or'] ?? []),
|
||||
'fi' => $sanitiseStrings($_GET['fi'] ?? []),
|
||||
'kw' => $sanitiseStrings($_GET['kw'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise and collect valid text search parameters from $_GET.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -174,34 +234,6 @@ class SearchController
|
||||
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<int, array{id: int, authors: string}> $students
|
||||
* @return array<string, int>
|
||||
*/
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user