mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
feat(home): htmx lazy-load cover images
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.
This commit is contained in:
@@ -157,6 +157,8 @@ class HomeController
|
||||
// Layout
|
||||
'currentNav' => '',
|
||||
'extraCss' => ['/assets/dist/public.min.css'],
|
||||
// htmx powers the `revealed` lazy-loaded cover images on this page
|
||||
'extraJs' => ['/assets/js/vendor/htmx.min.js'],
|
||||
'bodyClass' => 'home-body',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -49,6 +49,13 @@ trait ThesisFileHandler
|
||||
/** Cover image max size. */
|
||||
private const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||
|
||||
/**
|
||||
* WebP cover-thumbnail tuning (served to the public home grid).
|
||||
* `cwebp` must be installed on the server (see docs/environment.md).
|
||||
*/
|
||||
private const COVER_WEBP_MAX_WIDTH = 800; // px, tall-side kept proportional
|
||||
private const COVER_WEBP_QUALITY = 80;
|
||||
|
||||
/** MIME types accepted for thesis files. */
|
||||
private const ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
@@ -137,6 +144,104 @@ trait ThesisFileHandler
|
||||
$mimeType
|
||||
);
|
||||
error_log("ThesisFileHandler: cover uploaded → $relPath");
|
||||
|
||||
$this->generateCoverWebp($targetPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a small WebP cover thumbnail next to the original cover.
|
||||
*
|
||||
* Covers are uploaded as full-resolution PNG/JPEG and — on the public home
|
||||
* grid — rendered into a small 4:3 card. Serving the original is wasteful
|
||||
* (up to ~15 MB for a ~300 px card). `cwebp -resize` both downscales and
|
||||
* re-encodes in one pass, typically cutting payload by ~95%+.
|
||||
*
|
||||
* The thumbnail is a plain sidecar file (`{prefix}_COUVERTURE.webp`); it is
|
||||
* NOT registered in thesis_files. Consumption is via /media (webp MIME is
|
||||
* already allowed) using the same storage-relative path with a .webp ext.
|
||||
*
|
||||
* @param string $sourcePath Absolute path to the uploaded cover.
|
||||
*/
|
||||
private function generateCoverWebp(string $sourcePath): void
|
||||
{
|
||||
if (!is_file($sourcePath) || !is_readable($sourcePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targetPath = preg_replace('/\.[a-z0-9]+$/i', '.webp', $sourcePath);
|
||||
if ($targetPath === null || $targetPath === $sourcePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Best-effort: never break the upload if transcoding is unavailable
|
||||
// (no `cwebp`, PATH issues, etc.). A missing thumbnail simply makes the
|
||||
// public grid fall back to the original via the <picture> fallback.
|
||||
$cmd = sprintf(
|
||||
'cwebp -quiet -resize %d 0 -q %d %s -o %s 2>/dev/null',
|
||||
self::COVER_WEBP_MAX_WIDTH,
|
||||
self::COVER_WEBP_QUALITY,
|
||||
escapeshellarg($sourcePath),
|
||||
escapeshellarg($targetPath)
|
||||
);
|
||||
|
||||
$out = [];
|
||||
$code = 0;
|
||||
exec($cmd, $out, $code);
|
||||
|
||||
if ($code === 0 && is_file($targetPath)) {
|
||||
@chmod($targetPath, 0644);
|
||||
error_log('ThesisFileHandler: cover webp generated → ' . basename($targetPath));
|
||||
return;
|
||||
}
|
||||
|
||||
// cwebp can't read CMYK JPEGs ("Unsupported color conversion"); those
|
||||
// are increasingly common as scanned-cover uploads. GD (php-gd) handles
|
||||
// the CMYK→RGB conversion implicitly on decode, so fall back to it with
|
||||
// the same resize + quality before giving up.
|
||||
if ($this->generateCoverWebpWithGd($sourcePath, $targetPath)) {
|
||||
error_log('ThesisFileHandler: cover webp generated (GD fallback) → '
|
||||
. basename($targetPath));
|
||||
return;
|
||||
}
|
||||
|
||||
error_log("ThesisFileHandler: webp generation failed for cover (cwebp exit $code)"
|
||||
. ' → ' . basename($targetPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* GD-based WebP thumbnail generation (CMYK-JPEG fallback for cwebp).
|
||||
*
|
||||
* @param string $sourcePath Absolute path to the original cover.
|
||||
* @param string $targetPath Absolute path to the .webp output.
|
||||
* @return bool true on success.
|
||||
*/
|
||||
private function generateCoverWebpWithGd(string $sourcePath, string $targetPath): bool
|
||||
{
|
||||
if (!function_exists('imagecreatefromjpeg') || !function_exists('imagewebp')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$im = @imagecreatefromjpeg($sourcePath);
|
||||
if ($im === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$srcW = imagesx($im);
|
||||
$srcH = imagesy($im);
|
||||
$dstW = min(self::COVER_WEBP_MAX_WIDTH, $srcW);
|
||||
$dstH = (int) round($srcH * $dstW / $srcW);
|
||||
|
||||
$tgt = imagecreatetruecolor($dstW, $dstH);
|
||||
imagecopyresampled($tgt, $im, 0, 0, 0, 0, $dstW, $dstH, $srcW, $srcH);
|
||||
$ok = imagewebp($tgt, $targetPath, self::COVER_WEBP_QUALITY);
|
||||
|
||||
imagedestroy($im);
|
||||
imagedestroy($tgt);
|
||||
|
||||
if ($ok) {
|
||||
@chmod($targetPath, 0644);
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -979,6 +1084,10 @@ trait ThesisFileHandler
|
||||
);
|
||||
error_log("ThesisFileHandler: $queueKey uploaded (filepond) → $targetName");
|
||||
|
||||
if ($queueKey === 'cover') {
|
||||
$this->generateCoverWebp($targetPath);
|
||||
}
|
||||
|
||||
$this->cleanupFilePondTmp($fileId);
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,39 @@ class Dispatcher
|
||||
};
|
||||
}
|
||||
|
||||
// /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 () {
|
||||
|
||||
Reference in New Issue
Block a user