mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
Replace the eager <img> on the home page with an htmx placeholder <figure> that fetches a /cover-fragment endpoint when it scrolls into view (hx-trigger="revealed"), so heavy cover bytes load only on demand. Add spinner + settle-fade transition CSS, and load htmx.min.js on home.
244 lines
9.5 KiB
PHP
244 lines
9.5 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Front-controller Dispatcher
|
|
*
|
|
* Routes all public-page requests through a single entry point.
|
|
* Admin panel (/admin/*) and static assets bypass the dispatcher.
|
|
*
|
|
* Routes:
|
|
* / → HomeController → home view
|
|
* /search.php → SearchController → search view
|
|
* /repertoire → SearchController → repertoire view
|
|
* /tfe/<id> → TfeController → tfe view
|
|
* /apropos → AboutController → about view
|
|
* /licence → LicenceController → licence view
|
|
* /charte → CharteController → charte view
|
|
* /media.php → MediaController (direct output)
|
|
* /live-reload → LiveReloadController (direct output)
|
|
* /partage/<slug> → share-link flow
|
|
* /maintenance.php → static maintenance page
|
|
*/
|
|
class Dispatcher
|
|
{
|
|
private const ROUTES = [
|
|
'' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
|
|
'/' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
|
|
'/index.php' => ['controller' => 'HomeController', 'action' => 'handle', 'view' => 'public/home'],
|
|
'/search' => ['controller' => 'SearchController', 'action' => 'handleSearch', 'view' => 'public/search'],
|
|
'/search.php' => ['controller' => 'SearchController', 'action' => 'handleSearch', 'view' => 'public/search'],
|
|
'/repertoire' => ['controller' => 'SearchController', 'action' => 'handleRepertoire', 'view' => 'public/repertoire'],
|
|
'/repertoire.php' => ['controller' => 'SearchController', 'action' => 'handleRepertoire', 'view' => 'public/repertoire'],
|
|
'/tfe' => ['controller' => 'TfeController', 'action' => 'handle', 'view' => 'public/tfe'],
|
|
'/tfe.php' => ['controller' => 'TfeController', 'action' => 'handle', 'view' => 'public/tfe'],
|
|
'/apropos' => ['controller' => 'AboutController', 'action' => 'handle', 'view' => 'public/about'],
|
|
'/apropos.php' => ['controller' => 'AboutController', 'action' => 'handle', 'view' => 'public/about'],
|
|
'/licence' => ['controller' => 'LicenceController', 'action' => 'handle', 'view' => 'public/licence'],
|
|
'/licence.php' => ['controller' => 'LicenceController', 'action' => 'handle', 'view' => 'public/licence'],
|
|
'/charte' => ['controller' => 'CharteController', 'action' => 'handle', 'view' => 'public/charte'],
|
|
'/charte.php' => ['controller' => 'CharteController', 'action' => 'handle', 'view' => 'public/charte'],
|
|
];
|
|
|
|
private string $path;
|
|
private array $queryParams;
|
|
|
|
public function __construct()
|
|
{
|
|
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
|
$this->path = $uri;
|
|
$this->queryParams = $_GET;
|
|
}
|
|
|
|
/**
|
|
* Resolve the URI to a route, instantiate the controller,
|
|
* execute the action, and render the view.
|
|
*/
|
|
public function dispatch(): void
|
|
{
|
|
// 1. Direct-response endpoints (render their own output)
|
|
$direct = $this->matchDirect();
|
|
if ($direct) {
|
|
$direct();
|
|
return;
|
|
}
|
|
|
|
// Ensure session + CSRF token are initialised for routed pages
|
|
require_once APP_ROOT . '/src/App.php';
|
|
App::boot();
|
|
|
|
// 2. Routed pages (controller + view)
|
|
$route = $this->matchRoute();
|
|
if (!$route) {
|
|
http_response_code(404);
|
|
$this->render('public/not-found', [
|
|
'pageTitle' => 'Page non trouvée',
|
|
'metaDescription' => '',
|
|
'bodyClass' => 'page-not-found',
|
|
'extraCss' => ['/assets/dist/not-found.min.css'],
|
|
]);
|
|
return;
|
|
}
|
|
|
|
// 3. Load controller
|
|
$ctrlClass = $route['controller'];
|
|
require_once APP_ROOT . '/src/Controllers/' . $ctrlClass . '.php';
|
|
|
|
$controller = $ctrlClass::create();
|
|
$vars = $controller->{$route['action']}();
|
|
|
|
// 4. Render view
|
|
$this->render($route['view'], $vars);
|
|
}
|
|
|
|
/**
|
|
* Match endpoints that render their own response (no view layer).
|
|
*/
|
|
private function matchDirect(): ?callable
|
|
{
|
|
$path = $this->path;
|
|
|
|
// /live-reload
|
|
if ($path === '/live-reload' || $path === '/live-reload.php') {
|
|
return function () {
|
|
require_once APP_ROOT . '/src/Controllers/LiveReloadController.php';
|
|
$controller = new LiveReloadController(APP_ROOT);
|
|
$result = $controller->handle();
|
|
header('Content-Type: application/json');
|
|
echo json_encode($result['body']);
|
|
};
|
|
}
|
|
|
|
// /media.php
|
|
if ($path === '/media' || $path === '/media.php') {
|
|
return function () {
|
|
require_once APP_ROOT . '/src/Controllers/MediaController.php';
|
|
$controller = new MediaController();
|
|
$controller->handle();
|
|
};
|
|
}
|
|
|
|
// /cover-fragment (HTMX lazy-load: returns just the cover markup.
|
|
// Consumed by the home page's `revealed`-triggered placeholders — the
|
|
// heavy image bytes are fetched only when a card scrolls into view.
|
|
//
|
|
// Prefers the small WebP thumbnail (same storage-relative path, .webp
|
|
// ext) when present, else falls back to the original via <picture>.
|
|
if ($path === '/cover-fragment' || $path === '/cover-fragment.php') {
|
|
return static function (): void {
|
|
$coverPath = $_GET['path'] ?? '';
|
|
|
|
// Same strict whitelist as MediaController: no traversal, no junk.
|
|
if (
|
|
$coverPath === ''
|
|
|| !preg_match('#^[a-zA-Z0-9/_\-.]+$#', $coverPath)
|
|
) {
|
|
http_response_code(400);
|
|
exit;
|
|
}
|
|
|
|
$orig = '/media?path=' . urlencode($coverPath);
|
|
$webpPath = preg_replace('/\.[a-z0-9]+$/i', '.webp', $coverPath);
|
|
|
|
echo '<picture>'
|
|
. '<source type="image/webp" srcset="'
|
|
. htmlspecialchars('/media?path=' . urlencode((string) $webpPath), ENT_QUOTES, 'UTF-8')
|
|
. '">'
|
|
. '<img src="'
|
|
. htmlspecialchars($orig, ENT_QUOTES, 'UTF-8')
|
|
. '" alt="Couverture" loading="lazy">'
|
|
. '</picture>';
|
|
};
|
|
}
|
|
|
|
// /maintenance.php
|
|
if ($path === '/maintenance' || $path === '/maintenance.php') {
|
|
return function () {
|
|
require APP_ROOT . '/public/maintenance.php';
|
|
};
|
|
}
|
|
|
|
// /repertoire/student-preview (HTMX popover)
|
|
if ($path === '/repertoire/student-preview') {
|
|
return function () {
|
|
require_once APP_ROOT . '/src/Database.php';
|
|
require_once APP_ROOT . '/src/RateLimit.php';
|
|
require_once APP_ROOT . '/src/Controllers/SearchController.php';
|
|
$controller = SearchController::create();
|
|
$controller->handleStudentPreview();
|
|
};
|
|
}
|
|
|
|
// /partage/retry-email (GET: show retry form, POST: resend)
|
|
if ($path === '/partage/retry-email') {
|
|
return function () {
|
|
require APP_ROOT . '/public/partage/retry-email.php';
|
|
};
|
|
}
|
|
|
|
// /partage/*
|
|
if (preg_match('#^/partage(/.*)?$#', $path)) {
|
|
return function () {
|
|
require APP_ROOT . '/public/partage/index.php';
|
|
};
|
|
}
|
|
|
|
// /validate-access (GET: confirmation page, POST: token redemption)
|
|
if ($path === '/validate-access' || $path === '/validate-access.php') {
|
|
return function () {
|
|
require APP_ROOT . '/public/validate-access.php';
|
|
};
|
|
}
|
|
|
|
// /request-access (POST: submit access request)
|
|
if ($path === '/request-access' || $path === '/request-access.php') {
|
|
return function () {
|
|
require APP_ROOT . '/public/request-access.php';
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Match the current path against the static route table.
|
|
* Supports exact match and prefix-based (for /tfe?id=).
|
|
*/
|
|
private function matchRoute(): ?array
|
|
{
|
|
$path = $this->path;
|
|
|
|
// Exact match first
|
|
if (isset(self::ROUTES[$path])) {
|
|
return self::ROUTES[$path];
|
|
}
|
|
|
|
// /tfe?id= pattern (TfeController handles the id param internally)
|
|
if (preg_match('#^/tfe$#', $path) && isset($_GET['id'])) {
|
|
return self::ROUTES['/tfe'];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Render a view template wrapped in the full page layout.
|
|
* Includes head.php, header.php, the view, and footer.php.
|
|
*/
|
|
private function render(string $view, array $vars): void
|
|
{
|
|
require_once APP_ROOT . '/src/EmailObfuscator.php';
|
|
|
|
$viewPath = APP_ROOT . '/templates/' . $view . '.php';
|
|
if (!file_exists($viewPath)) {
|
|
http_response_code(500);
|
|
echo "View not found: {$viewPath}";
|
|
return;
|
|
}
|
|
extract($vars);
|
|
include APP_ROOT . '/templates/head.php';
|
|
include APP_ROOT . '/templates/header.php';
|
|
include $viewPath;
|
|
include APP_ROOT . '/templates/footer.php';
|
|
}
|
|
}
|