mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
- add exemplaire_baiu, exemplaire_erg, cc4r, remarks; - add is_ulb to jury; - split jury_lecteurs into interne/externe in view; - refactor admin edit form with backoffice fields; - update public fiche to show promoteur ULB and split lecteurs
279 lines
10 KiB
PHP
279 lines
10 KiB
PHP
<?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://xamxam.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);
|
||
|
||
// Check if restricted files feature is enabled and user has access
|
||
$restrictedEnabled = $this->db->isRestrictedFilesEnabled();
|
||
$hasRestrictedAccess = false;
|
||
|
||
if ($restrictedEnabled && $accessTypeId === 2) {
|
||
// Check for cookie-based access
|
||
$cookieToken = $_COOKIE['tfe_access_' . $thesisId] ?? null;
|
||
if ($cookieToken) {
|
||
$hasRestrictedAccess = $this->db->hasValidCookieAccess($cookieToken, $thesisId);
|
||
}
|
||
}
|
||
|
||
// If access is restricted and user doesn't have valid access, hide files
|
||
$shouldHideFiles = ($restrictedEnabled && $accessTypeId === 2 && !$hasRestrictedAccess);
|
||
|
||
// 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'] : '')
|
||
. ' – XAMXAM';
|
||
|
||
return [
|
||
// Core data
|
||
'thesisId' => $thesisId,
|
||
'data' => $data,
|
||
'accessTypeId' => $accessTypeId,
|
||
'isInterdit' => $isInterdit,
|
||
'captionFiles' => $captionFiles,
|
||
'juryPresidents' => $juryByRole['presidents'],
|
||
'promoteursInternes' => $juryByRole['internes'],
|
||
'promoteursExternes' => $juryByRole['externes'],
|
||
'promoteursUlb' => $juryByRole['ulb'],
|
||
'juryLecteursInternes' => $juryByRole['lecteurs_internes'],
|
||
'juryLecteursExternes' => $juryByRole['lecteurs_externes'],
|
||
|
||
// Restricted files access
|
||
'restrictedEnabled' => $restrictedEnabled,
|
||
'hasRestrictedAccess' => $hasRestrictedAccess,
|
||
'shouldHideFiles' => $shouldHideFiles,
|
||
|
||
// 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 – XAMXAM, 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' => 'XAMXAM – ERG',
|
||
'article_author' => $data['authors'] ?? '',
|
||
'article_published_time' => !empty($data['year']) ? $data['year'] . '-01-01' : '',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Split jury members by role and internal/external/ULB flags.
|
||
*
|
||
* @param array<int, array<string, mixed>> $jury
|
||
* @return array{presidents: list<string>, internes: list<string>, externes: list<string>, ulb: list<string>, lecteurs_internes: list<string>, lecteurs_externes: list<string>}
|
||
*/
|
||
private function splitJuryByRole(array $jury): array
|
||
{
|
||
$result = ['presidents' => [], 'internes' => [], 'externes' => [], 'ulb' => [], 'lecteurs_internes' => [], 'lecteurs_externes' => []];
|
||
|
||
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_ulb'] ?? 0) === 1) {
|
||
$result['ulb'][] = $name;
|
||
} elseif ((int)$member['is_external'] === 1) {
|
||
$result['externes'][] = $name;
|
||
} else {
|
||
$result['internes'][] = $name;
|
||
}
|
||
break;
|
||
case 'lecteur':
|
||
if ((int)$member['is_external'] === 1) {
|
||
$result['lecteurs_externes'][] = $name;
|
||
} else {
|
||
$result['lecteurs_internes'][] = $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;
|
||
}
|
||
}
|