diff --git a/TODO.md b/TODO.md index bbbcd78..a18b532 100644 --- a/TODO.md +++ b/TODO.md @@ -4,8 +4,11 @@ > Context: CSS waste strategy — full stream DEFERRED (tasks 10/u/w/x/y/z): unusedSymbols report showed only ~6.2KB/2.9% of 216KB reclaimable; pruning + per-page split parked (docs/css-split-analysis.md). #t (Interdit admin route) confirmed already implemented via /admin/media.php + media-viewer.php + recapitulatif link. No pending live tasks. ## In Progress +- [x] #cover-home-lazy-intersect-fix [!high] Fix lazy covers not loading after initial images. Root cause: home grid scrolls inside
(overflow-y:auto, body height:100vh flex), but htmx `revealed` listens to window scroll only — so below-fold covers never revealed. Fix: hx-trigger="intersect threshold:0.05 once" (IntersectionObserver works inside any scroll container). VERIFIED LIVE: progressive scroll loads covers 5→11→15→20→22/22 (before: stuck ~5). Committed fix(home): use intersect trigger. +- [x] #cover-image-webp-avif-serve-lite [!medium] Serve lightweight webp cover thumbnails instead of full-res PNG originals (~99% on worst cases). DONE VERIFIED LIVE 2026-08-31: `cwebp -resize 800 q80` sidecars (+ GD fallback for CMYK JPEGs) generated by ThesisFileHandler at upload (FilePond + legacy); /cover-fragment emits (webp source + original fallback); backfill scripts/generate-cover-webp.php + `just deploy-cover-webp`. PROD: tools installed (docs), 46/46 covers backfilled (44 cwebp + 2 GD/CMYK), app code deployed, /cover-fragment + /media?path=*.webp verified 200 image/webp 61KB (vs 15.8MB orig). AVIF dropped (no viable prod resize; WEBP sufficient). NOTE: just deploy-code's final nginx/permissions step needs sudo password (operational, pre-existing — not a regression). ## Pending +- [x] #home-htmx-lazy-cover-images [!medium] Add htmx lazyloading on home page cover images: `revealed`-triggered cover-fragment endpoint, swap-in ``, settle fade CSS, load htmx on home ## Completed - [x] #audit-all-docs-and [!high] Audit all docs/ and classify accurate vs stale diff --git a/app/public/assets/css/public.css b/app/public/assets/css/public.css index b742603..09d394d 100644 --- a/app/public/assets/css/public.css +++ b/app/public/assets/css/public.css @@ -78,7 +78,15 @@ height: 100%; object-fit: cover; display: block; - transition: transform 0.3s ease; + transition: transform 0.3s ease, opacity 300ms ease-in; +} + +/* Cover thumbnails are served as a (WebP + fallback); keep the + wrapper block-level so the fills the 4:3 figure. */ +.home-body figure picture { + display: block; + width: 100%; + height: 100%; } .card:hover figure img, @@ -97,6 +105,26 @@ font-size: var(--step-3); } +/* HTMX lazy-loaded cover: spinner + settle fade-in (mirrors htmx + docs' `htmx-settling img` transition pattern). */ +.card__cover-loading { + animation: card-cover-pulse 1.1s ease-in-out infinite; +} + +@keyframes card-cover-pulse { + 0%, + 100% { + opacity: 0.3; + } + 50% { + opacity: 1; + } +} + +.home-body figure.htmx-settling img { + opacity: 0; +} + .card__media--gradient { width: 100%; aspect-ratio: 4 / 3; diff --git a/app/src/Controllers/HomeController.php b/app/src/Controllers/HomeController.php index dd99b7a..0c1305a 100644 --- a/app/src/Controllers/HomeController.php +++ b/app/src/Controllers/HomeController.php @@ -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', ]; } diff --git a/app/src/Controllers/ThesisFileHandler.php b/app/src/Controllers/ThesisFileHandler.php index 349377a..07cd5b1 100644 --- a/app/src/Controllers/ThesisFileHandler.php +++ b/app/src/Controllers/ThesisFileHandler.php @@ -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 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); } diff --git a/app/src/Dispatcher.php b/app/src/Dispatcher.php index c98e83b..e085489 100644 --- a/app/src/Dispatcher.php +++ b/app/src/Dispatcher.php @@ -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 . + 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 '' + . '' + . 'Couverture' + . ''; + }; + } + // /maintenance.php if ($path === '/maintenance' || $path === '/maintenance.php') { return function () { diff --git a/app/templates/public/home.php b/app/templates/public/home.php index c722490..ecd2d56 100644 --- a/app/templates/public/home.php +++ b/app/templates/public/home.php @@ -17,10 +17,14 @@ $thumb = $coverMap[$item['id']] ?? null; ?> -
- Couverture — <?= htmlspecialchars($item['title']) ?> par <?= htmlspecialchars($item['authors'] ?? '') ?> +
+
+ … +
=8.4"`, platform lock `8.4`). @@ -36,7 +53,9 @@ Verified present in the production `php -m` output and on the FPM pool: - `session` — admin auth + CSRF (see [`security.md`](security.md)) - `sodium` — `Crypto` (libsodium) for encrypted fields (SMTP password) - `zlib` / `phar` — Composer autoload + PHAR-based tooling -- `gd` — not used directly, present transitively (image handling is `finfo`) +- `gd` — **intentionally installed** (`php-gd`, 2026-08-31) for cover-image + transcoding to WebP/AVIF; resize/encode derivations for the home-page cover + grid. Not used for MIME validation (that is `finfo`). - `fileinfo` — upload MIME validation (`finfo`) - `calendar`, `ctype`, `filter`, `hash`, `tokenizer`, `xml`, `libxml` — PHP core extensions, present by default diff --git a/justfile b/justfile index acda458..0cc9726 100644 --- a/justfile +++ b/justfile @@ -176,6 +176,16 @@ deploy-migrate: ssh xamxam "cd /var/www/xamxam && REPO_ROOT=/var/www/xamxam bash /tmp/migrate.sh" ssh xamxam "rm -f /tmp/migrate.sh" +[group('deploy')] +deploy-cover-webp dry_run='': + # Backfill small WebP cover thumbnails on the server (idempotent). + # Dry-run by default; pass --no-dry-run to actually generate. Runs as the + # SSH user (member of the xamxam group, dirs are setgid group-writable) so + # no sudo is needed. Deploy as www-data if group-write ever changes. + rsync -v scripts/generate-cover-webp.php xamxam:/tmp/generate-cover-webp.php + ssh xamxam "cd /var/www/xamxam && php /tmp/generate-cover-webp.php {{dry_run}}" + ssh xamxam "rm -f /tmp/generate-cover-webp.php" + [group('deploy')] deploy-env: #!/usr/bin/env bash diff --git a/scripts/generate-cover-webp.php b/scripts/generate-cover-webp.php new file mode 100644 index 0000000..fb87e38 --- /dev/null +++ b/scripts/generate-cover-webp.php @@ -0,0 +1,152 @@ +#!/usr/bin/env php + 100) { + $quality = 80; +} + +if (!is_dir($storageRoot) || !is_readable($storageRoot)) { + fwrite(STDERR, "[generate-cover-webp] storage root not readable: $storageRoot\n"); + exit(1); +} + +if (!function_exists('exec')) { + fwrite(STDERR, "[generate-cover-webp] exec() is disabled; cannot run cwebp.\n"); + exit(1); +} + +// Recursively collect cover files. +$covers = []; +$rii = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($storageRoot, FilesystemIterator::SKIP_DOTS) +); +foreach ($rii as $entry) { + if (!$entry->isFile()) { + continue; + } + $name = $entry->getFilename(); + // Only source covers (original upload) count as inputs; the generated + // `.webp` sidecars must NOT be re-scanned as sources. + if (!preg_match('/_COUVERTURE\.[a-z0-9]+$/i', $name) || str_ends_with($name, '.webp')) { + continue; + } + $covers[] = $entry->getPathname(); +} +sort($covers); + +$pending = []; +$existing = 0; +foreach ($covers as $sourcePath) { + $webpPath = preg_replace('/\.[a-z0-9]+$/i', '.webp', $sourcePath); + if ($webpPath !== null && is_file($webpPath)) { + $existing++; + continue; + } + $pending[] = $sourcePath; +} + +$skipped = 0; +if ($dryRun) { + foreach ($pending as $p) { + printf("DRY-RUN → %s (%d bytes)\n", $p, (int) filesize($p)); + } + printf( + "Found %d cover file(s): %d already have a .webp thumbnail, %d pending.\n", + count($covers), + $existing, + count($pending) + ); + echo "Re-run with --no-dry-run to generate them.\n"; + exit(0); +} + +foreach ($pending as $sourcePath) { + $targetPath = preg_replace('/\.[a-z0-9]+$/i', '.webp', $sourcePath); + $cmd = sprintf( + 'cwebp -quiet -resize %d 0 -q %d %s -o %s 2>/dev/null', + $maxWidth, + $quality, + escapeshellarg((string) $sourcePath), + escapeshellarg((string) $targetPath) + ); + + exec($cmd, $_, $code); + $ok = $code === 0 && $targetPath !== null && is_file($targetPath); + + // cwebp can't read CMYK JPEGs; fall back to GD (php-gd) which handles + // CMYK->RGB on decode, same resize + quality. + if (!$ok && function_exists('imagecreatefromjpeg') && function_exists('imagewebp')) { + $im = @imagecreatefromjpeg((string) $sourcePath); + if ($im !== false && $targetPath !== null) { + $srcW = imagesx($im); + $srcH = imagesy($im); + $dstW = min($maxWidth, $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, $quality); + imagedestroy($im); + imagedestroy($tgt); + if ($ok) { + @chmod($targetPath, 0644); + } + } + } + + if ($ok && $targetPath !== null && is_file($targetPath)) { + @chmod($targetPath, 0644); + printf("✓ %s (%d → %d bytes)\n", basename($targetPath), (int) filesize($sourcePath), (int) filesize($targetPath)); + } else { + printf("✗ failed: %s (cwebp exit %d)\n", (string) $sourcePath, $code); + $skipped++; + } +} + +printf( + "Generated %d WebP thumbnail(s); %d failed. %d already present.\n", + count($pending) - $skipped, + $skipped, + $existing +); +exit($skipped > 0 ? 1 : 0);