Files
xamxam/app/src/Controllers/TfeController.php
Pontoporeia de2e7a61ee feat: single entry point routing — convert to front controller pattern
- Create app/public/index.php as front controller (bootstrap + Dispatcher)
- Rewrite app/router.php for PHP dev server → all non-asset requests to index.php
- Update Dispatcher to render full page layouts (head+header+view+footer)
- Move public view templates into templates/public/ (home, search, tfe, about, repertoire)
- Delete dead direct-access public/*.php files (apropos, search, tfe, licence, repertoire)
- Add clean URL routes to Dispatcher (/search, /tfe, /repertoire, /apropos, /licence, /media)
- Remove .php extensions from all internal links (header, views, templates, URLs)
- Update OG tags in controllers to use clean URLs
- Update nginx posterg.conf → front-controller try_files pattern, block direct .php access
- Update header.php and search-bar.php form actions to clean URLs
- Switch AboutController nav key from 'nav' to 'currentNav' for consistency
2026-04-20 12:42:15 +02:00

248 lines
8.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* TfeController
*
* Handles all data-fetching and view-variable assembly for the public TFE
* detail page (public/tfe.php).
*
* Responsibilities:
* - Validate the `id` GET parameter and load the thesis record
* - Enforce publication visibility (redirect to index on 404)
* - Resolve the OG image (banner → first image file)
* - Build the complete OG / Twitter Card tag array
* - Assemble the meta description from the synopsis
* - Collect WebVTT caption file paths for video pairing
* - Return a flat array of view variables ready for template extraction
*
* The class has NO output side-effects; all template rendering stays in
* public/tfe.php so the view layer remains easy to inspect and modify.
*/
class TfeController
{
private const BASE_URL = 'https://posterg.erg.be';
private const META_MAX_LEN = 160;
private Database $db;
public function __construct(Database $db)
{
$this->db = $db;
}
// ── Factory ───────────────────────────────────────────────────────────────
/**
* Convenience factory: loads the Database singleton and returns a ready
* controller instance.
*/
public static function create(): self
{
require_once APP_ROOT . '/src/Database.php';
return new self(Database::getInstance());
}
// ── Main entry point ─────────────────────────────────────────────────────
/**
* Process the current request.
*
* On success returns an array of view variables.
* On failure (missing id, thesis not found) sends a redirect and exits.
*
* @return array<string, mixed>
*/
public function handle(): array
{
$thesisId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($thesisId <= 0) {
$this->redirectHome();
}
try {
$data = $this->db->getThesisById($thesisId);
} catch (Exception $e) {
error_log('TfeController: ' . $e->getMessage());
$this->redirectHome();
}
if (empty($data)) {
$this->redirectHome();
}
// Access type (1 = open, 2 = restricted, 3 = forbidden)
$accessTypeId = $this->db->getThesisAccessTypeId($thesisId) ?? 1;
$isInterdit = ($accessTypeId === 3);
// Caption (WebVTT) files — N-th VTT is paired with the N-th <video>
$captionFiles = $this->collectCaptionPaths($data['files'] ?? []);
// Jury members with interne/externe split
$jury = $this->db->getThesisJury($thesisId);
$juryByRole = $this->splitJuryByRole($jury);
// Page meta
$metaDescription = $this->buildMetaDescription($data['synopsis'] ?? '');
$ogTags = $this->buildOgTags($data, $thesisId, $metaDescription);
$pageTitle = $data['title']
. (!empty($data['authors']) ? ' ' . $data['authors'] : '')
. ' Posterg';
return [
// Core data
'thesisId' => $thesisId,
'data' => $data,
'accessTypeId' => $accessTypeId,
'isInterdit' => $isInterdit,
'captionFiles' => $captionFiles,
'juryPresidents' => $juryByRole['presidents'],
'promoteursInternes' => $juryByRole['internes'],
'promoteursExternes' => $juryByRole['externes'],
'juryLecteurs' => $juryByRole['lecteurs'],
// Page meta
'pageTitle' => $pageTitle,
'metaDescription' => $metaDescription,
'ogTags' => $ogTags,
// Layout
'currentNav' => '',
'extraCss' => ['/assets/css/tfe.css'],
'bodyClass' => 'tfe-body',
];
}
// ── Private helpers ───────────────────────────────────────────────────────
/**
* Build a ~160-character meta description from the thesis synopsis.
*/
private function buildMetaDescription(string $synopsis): string
{
$plain = strip_tags($synopsis);
if (empty($plain)) {
return 'Mémoire de fin d\'études Posterg, répertoire des TFE de l\'erg.';
}
return strlen($plain) > self::META_MAX_LEN
? substr($plain, 0, self::META_MAX_LEN - 3) . '…'
: $plain;
}
/**
* Resolve the OG image URL: banner_path → first image file → empty string.
*
* @param array<int, array<string, mixed>> $files
*/
private function resolveOgImage(array $files, ?string $bannerPath): string
{
if (!empty($bannerPath)) {
return self::BASE_URL . '/media.php?path=' . rawurlencode($bannerPath);
}
foreach ($files as $file) {
$ext = strtolower(pathinfo($file['file_path'] ?? '', PATHINFO_EXTENSION));
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) {
return self::BASE_URL . '/media.php?path=' . rawurlencode($file['file_path']);
}
}
return '';
}
/**
* Build the complete $ogTags array consumed by templates/head.php.
*
* @param array<string, mixed> $data
* @return array<string, string>
*/
private function buildOgTags(array $data, int $thesisId, string $metaDescription): array
{
$ogImage = $this->resolveOgImage($data['files'] ?? [], $data['banner_path'] ?? null);
$title = $data['title'] . (!empty($data['authors']) ? ' ' . $data['authors'] : '');
$imageAlt = $data['title'] . (!empty($data['authors']) ? ' par ' . $data['authors'] : '');
return [
'type' => 'article',
'title' => $title,
'description' => $metaDescription,
'url' => self::BASE_URL . '/tfe?id=' . $thesisId,
'image' => $ogImage,
'image_alt' => $imageAlt,
'site_name' => 'Posterg ERG',
'article_author' => $data['authors'] ?? '',
'article_published_time' => !empty($data['year']) ? $data['year'] . '-01-01' : '',
];
}
/**
* Split jury members by role and internal/external flag.
*
* @param array<int, array<string, mixed>> $jury
* @return array{presidents: list<string>, internes: list<string>, externes: list<string>, lecteurs: list<string>}
*/
private function splitJuryByRole(array $jury): array
{
$result = ['presidents' => [], 'internes' => [], 'externes' => [], 'lecteurs' => []];
foreach ($jury as $member) {
$name = $member['name'] ?? '';
if ($name === '') continue;
switch ($member['role']) {
case 'president':
$result['presidents'][] = $name;
break;
case 'promoteur':
if ((int)$member['is_external'] === 1) {
$result['externes'][] = $name;
} else {
$result['internes'][] = $name;
}
break;
case 'lecteur':
$result['lecteurs'][] = $name;
break;
}
}
return $result;
}
/**
* Return an ordered list of VTT caption file paths from the files array.
* The N-th entry corresponds to the N-th <video> element in document order.
*
* @param array<int, array<string, mixed>> $files
* @return list<string>
*/
private function collectCaptionPaths(array $files): array
{
$captions = [];
foreach ($files as $file) {
$mime = $file['mime_type'] ?? '';
$ext = strtolower(pathinfo($file['file_path'] ?? '', PATHINFO_EXTENSION));
if ($mime === 'text/vtt' || $ext === 'vtt') {
$captions[] = $file['file_path'];
}
}
return $captions;
}
// ── Response helpers ──────────────────────────────────────────────────────
/**
* Redirect to the home page and terminate. Never returns.
*/
private function redirectHome(): never
{
header('Location: /');
exit;
}
}