feat(images): JPEG XL optimizer with webp/jpg fallback and <picture> serving
img_optimize() decodes .jxl with djxl or Imagick, resizes with GD, re-encodes with cjxl and writes foto.jxl.webp beside it; skips cleanly without a decoder. Gallery, hero, navbar logos and JSON-LD serve the JXL with the fallback, and trash/removal move the fallback with its .jxl.
This commit is contained in:
1 parent
8fe73eb5d6
commit
4f44bc1609
14 files changed
+260
-20
No files matched your search
@@ -83,7 +83,12 @@ Each gym has a brand colour (`gyms.brand_color`, `#RRGGBB`, migration `migration
|
|||||||
An unpublished gym 404s publicly but previews for staff.
|
An unpublished gym 404s publicly but previews for staff.
|
||||||
|
|
||||||
## Imagens / JPEG XL (2026-10-26)
|
## Imagens / JPEG XL (2026-10-26)
|
||||||
Fleet transition to JPEG XL (root `Y:\GitTea\CLAUDE.md`, "Images"); fitme3070 is the reference site. Uploads already accept `.jxl` (gyms header/logo, gallery photos). **To do:** `img_optimize()` (`config/functions_images.php`) still skips JXL because it is GD-only; it must resize/re-encode JXL (Imagick with JXL or `cjxl`/`djxl`, check what the dev `webdevops/php-apache:8.2` container and the VPS `fitme` image have) and write a `.webp`/`.jpg` fallback beside every `.jxl`; the gallery, hero and logos then serve `<picture>` with the JXL source and the fallback `<img>`, and the gallery listing groups files by base name. Every image input in the backoffice shows the current image next to it with a remove control (gyms form does; check events, modalidades, team, galerias, system settings).
|
Fleet transition to JPEG XL (root `Y:\GitTea\CLAUDE.md`, "Images"); fitme3070 is the reference site. Uploads already accept `.jxl` (gyms header/logo, gallery photos).
|
||||||
|
- **Optimizer** (`config/functions_images.php`, built 2026-10-26): `img_optimize()` hands `.jxl` to `img_jxl_optimize()`, which decodes with `djxl` (or Imagick if it lists JXL), resizes with GD, re-encodes a smaller `.jxl` with `cjxl -d 1.0` when resized, and always writes the fallback **`foto.jxl.webp`** (`.jxl.jpg` if GD has no WebP) beside it. Idempotent (skips when the fallback is newer and within the cap). With no decoder it returns `skipped` («JPEG XL sem descodificador no servidor»), uploads never fail.
|
||||||
|
- **No decoder exists yet** (checked 2026-10-26): the VPS `fitme` image (php:8.4-apache, Debian trixie) has GD only; the dev `webdevops/php-apache:8.2` has Imagick without JXL. Both need `libjxl-tools` (cjxl/djxl) installed; until then `.jxl` uploads have no fallback and show only in browsers with JXL (Safari). After installing, press «Otimizar» on each gallery to backfill.
|
||||||
|
- **Serving:** `img_tag($rel, $attrs)` (`<picture><source type="image/jxl"><img src=fallback>`, plain `<img>` otherwise), `img_css_bg($rel)` (hero: `url(fallback)` then `image-set()` with the JXL), `img_compat_rel()` (single-URL places: lightbox link, JSON-LD image, admin preview), `img_list_dir()` (gallery listings, hides `*.jxl.*`). Used in `site/includes/gallery.php` (lightbox shows the tile's `currentSrc`), `site/index.php` hero, `include.php` preload (`type="image/jxl"` for a JXL hero), `seo.php`, navbar logos. `picture { display: contents }` in `style.css` keeps the old img sizing.
|
||||||
|
- **Moves/deletes keep the pair together:** `img_fallback_follow($from, $to='')` in trash (`_trash_image_file`), `gyms_remove_image()`, `events_image_remove()`.
|
||||||
|
- Still open: events/modalidades/team uploads don't accept `.jxl` yet. Every image input in the backoffice shows the current image next to it with a remove control (gyms form does; check events, modalidades, team, galerias, system settings).
|
||||||
|
|
||||||
The navbar only links sections the gym shows **and** has content for (`site_section_has_content()` in `config/functions_site.php`: Galeria = photos in its folder, Modalidades, Equipa = active members (`site_team_active()`, also used by the page), Horário, Mensalidades). The gallery section background is a 7% brand-colour tint, not white.
|
The navbar only links sections the gym shows **and** has content for (`site_section_has_content()` in `config/functions_site.php`: Galeria = photos in its folder, Modalidades, Equipa = active members (`site_team_active()`, also used by the page), Horário, Mensalidades). The gallery section background is a 7% brand-colour tint, not white.
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ function events_image_remove(?string $filename): void {
|
|||||||
if ($filename === '' || strncmp($filename, 'upl_', 4) !== 0) return;
|
if ($filename === '' || strncmp($filename, 'upl_', 4) !== 0) return;
|
||||||
$path = events_image_dir() . $filename;
|
$path = events_image_dir() . $filename;
|
||||||
if (is_file($path)) @unlink($path);
|
if (is_file($path)) @unlink($path);
|
||||||
|
img_fallback_follow($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Leads ─────────────────────────────────────────────────────────────────────
|
// ─── Leads ─────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ function gyms_remove_image(?string $rel): void {
|
|||||||
if (!preg_match('~^uploads/gyms/[A-Za-z0-9_.-]+$~', $rel)) return;
|
if (!preg_match('~^uploads/gyms/[A-Za-z0-9_.-]+$~', $rel)) return;
|
||||||
$path = gyms_upload_dir() . basename($rel);
|
$path = gyms_upload_dir() . basename($rel);
|
||||||
if (is_file($path)) @unlink($path);
|
if (is_file($path)) @unlink($path);
|
||||||
|
img_fallback_follow($path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-field setter for views/admin/reviews/index.php — that page only
|
// Single-field setter for views/admin/reviews/index.php — that page only
|
||||||
|
|||||||
+226
-2
@@ -89,6 +89,7 @@ function img_optimize(string $path, int $max_dim): array {
|
|||||||
|
|
||||||
if (!is_file($path)) return $fail('ficheiro não encontrado');
|
if (!is_file($path)) return $fail('ficheiro não encontrado');
|
||||||
if (!extension_loaded('gd')) return $fail('extensão GD indisponível');
|
if (!extension_loaded('gd')) return $fail('extensão GD indisponível');
|
||||||
|
if (strtolower(pathinfo($path, PATHINFO_EXTENSION)) === 'jxl') return img_jxl_optimize($path, $max_dim);
|
||||||
|
|
||||||
$info = @getimagesize($path);
|
$info = @getimagesize($path);
|
||||||
if (!$info) return $skip('formato não suportado'); // SVG e outros não-raster
|
if (!$info) return $skip('formato não suportado'); // SVG e outros não-raster
|
||||||
@@ -186,9 +187,9 @@ function img_optimize_dir(string $dir, int $max_dim): array {
|
|||||||
if (!is_dir($dir)) return $stats;
|
if (!is_dir($dir)) return $stats;
|
||||||
foreach (scandir($dir) ?: [] as $file) {
|
foreach (scandir($dir) ?: [] as $file) {
|
||||||
$path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $file;
|
$path = rtrim($dir, '/\\') . DIRECTORY_SEPARATOR . $file;
|
||||||
if (!is_file($path)) continue;
|
if (!is_file($path) || img_is_jxl_fallback($file)) continue;
|
||||||
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||||
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp'], true)) continue;
|
if (!in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl'], true)) continue;
|
||||||
$r = img_optimize($path, $max_dim);
|
$r = img_optimize($path, $max_dim);
|
||||||
$stats[$r['status']]++;
|
$stats[$r['status']]++;
|
||||||
$stats['saved'] += $r['saved'];
|
$stats['saved'] += $r['saved'];
|
||||||
@@ -214,3 +215,226 @@ function img_human_bytes(int $bytes): string {
|
|||||||
if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KB';
|
if ($bytes >= 1024) return number_format($bytes / 1024, 0) . ' KB';
|
||||||
return $bytes . ' B';
|
return $bytes . ' B';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── JPEG XL ─────────────────────────────────────────────────────────────────
|
||||||
|
// O GD não lê JXL. Descodifica-se com os binários do libjxl (djxl/cjxl, pacote
|
||||||
|
// Debian libjxl-tools) ou com o Imagick se tiver o formato JXL; sem nenhum, o
|
||||||
|
// .jxl fica como foi enviado (status "skipped") e o upload não falha.
|
||||||
|
// Ao lado de cada foto.jxl grava-se foto.jxl.webp (ou .jpg sem WebP no GD):
|
||||||
|
// é a versão para os browsers sem JXL, servida por img_tag()/img_css_bg() e
|
||||||
|
// escondida das listagens (img_list_dir). Redimensiona-se a partir da versão
|
||||||
|
// descodificada e, havendo encoder, o .jxl é regravado ao novo tamanho.
|
||||||
|
|
||||||
|
const IMG_JXL_DISTANCE = '1.0'; // cjxl -d: 1.0 = visualmente sem perdas
|
||||||
|
|
||||||
|
// Backends on this server: ['imagick' => bool, 'djxl' => path|'', 'cjxl' => path|''].
|
||||||
|
function img_jxl_tools(): array {
|
||||||
|
static $tools = null;
|
||||||
|
if ($tools !== null) return $tools;
|
||||||
|
$imagick = class_exists('Imagick') && in_array('JXL', \Imagick::queryFormats('JXL'), true);
|
||||||
|
$find = static function (string $bin): string {
|
||||||
|
if (!function_exists('proc_open')) return '';
|
||||||
|
$dirs = array_merge(explode(PATH_SEPARATOR, (string) getenv('PATH')), ['/usr/bin', '/usr/local/bin']);
|
||||||
|
foreach (array_unique(array_filter($dirs)) as $d) {
|
||||||
|
$p = rtrim($d, '/\\') . DIRECTORY_SEPARATOR . $bin;
|
||||||
|
if (@is_file($p) && @is_executable($p)) return $p;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
return $tools = ['imagick' => $imagick, 'djxl' => $find('djxl'), 'cjxl' => $find('cjxl')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function img_jxl_can_decode(): bool {
|
||||||
|
$t = img_jxl_tools();
|
||||||
|
return $t['imagick'] || $t['djxl'] !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs a binary with an argument list (no shell). True when it exits 0 and wrote $out.
|
||||||
|
function _img_run(string $bin, array $args, string $out): bool {
|
||||||
|
$proc = @proc_open(array_merge([$bin], $args), [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||||
|
if (!is_resource($proc)) return false;
|
||||||
|
stream_get_contents($pipes[1]); stream_get_contents($pipes[2]);
|
||||||
|
fclose($pipes[1]); fclose($pipes[2]);
|
||||||
|
clearstatcache(true, $out);
|
||||||
|
return proc_close($proc) === 0 && is_file($out) && filesize($out) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function img_jxl_decode(string $jxl, string $png): bool {
|
||||||
|
$t = img_jxl_tools();
|
||||||
|
if ($t['djxl'] !== '' && _img_run($t['djxl'], [$jxl, $png], $png)) return true;
|
||||||
|
if ($t['imagick']) {
|
||||||
|
try {
|
||||||
|
$im = new \Imagick($jxl);
|
||||||
|
$im->setImageFormat('PNG');
|
||||||
|
return $im->writeImage($png) && is_file($png);
|
||||||
|
} catch (\Throwable $e) { return false; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function img_jxl_encode(string $png, string $jxl): bool {
|
||||||
|
$t = img_jxl_tools();
|
||||||
|
if ($t['cjxl'] !== '' && _img_run($t['cjxl'], [$png, $jxl, '-d', IMG_JXL_DISTANCE, '-e', '7'], $jxl)) return true;
|
||||||
|
if ($t['imagick']) {
|
||||||
|
try {
|
||||||
|
$im = new \Imagick($png);
|
||||||
|
$im->setImageFormat('JXL');
|
||||||
|
$im->setImageCompressionQuality(90);
|
||||||
|
return $im->writeImage($jxl) && is_file($jxl);
|
||||||
|
} catch (\Throwable $e) { return false; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "foto.jxl.webp" / "foto.jxl.jpg": the fallback copies, never listed as photos.
|
||||||
|
function img_is_jxl_fallback(string $name): bool {
|
||||||
|
return (bool) preg_match('/\.jxl\.(webp|jpe?g|png)$/i', $name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute path of the fallback beside $jxl_path, or '' when there is none.
|
||||||
|
function img_jxl_fallback(string $jxl_path): string {
|
||||||
|
foreach (['webp', 'jpg', 'png'] as $e) {
|
||||||
|
if (is_file($jxl_path . '.' . $e)) return $jxl_path . '.' . $e;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moves (or, with $to = '', deletes) the fallback that goes with an image file.
|
||||||
|
// Callers that move/delete an upload call this too, so the pair stays together.
|
||||||
|
function img_fallback_follow(string $from, string $to = ''): void {
|
||||||
|
if (!preg_match('/\.jxl$/i', $from)) return;
|
||||||
|
foreach (['webp', 'jpg', 'png'] as $e) {
|
||||||
|
if (!is_file($from . '.' . $e)) continue;
|
||||||
|
if ($to === '') @unlink($from . '.' . $e);
|
||||||
|
else @rename($from . '.' . $e, $to . '.' . $e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function img_jxl_optimize(string $path, int $max_dim): array {
|
||||||
|
$skip = fn(string $why) => ['status' => 'skipped', 'saved' => 0, 'reason' => $why];
|
||||||
|
$fail = fn(string $why) => ['status' => 'failed', 'saved' => 0, 'reason' => $why];
|
||||||
|
|
||||||
|
// Fallback newer than the .jxl and within the cap: nothing to do (idempotent).
|
||||||
|
$old = img_jxl_fallback($path);
|
||||||
|
if ($old !== '' && filemtime($old) >= filemtime($path)) {
|
||||||
|
$d = @getimagesize($old);
|
||||||
|
if ($d && $d[0] <= $max_dim && $d[1] <= $max_dim) return $skip('já dentro do limite');
|
||||||
|
}
|
||||||
|
if (!img_jxl_can_decode()) return $skip('JPEG XL sem descodificador no servidor (libjxl-tools)');
|
||||||
|
|
||||||
|
$dec = $path . '.dec.png';
|
||||||
|
if (!img_jxl_decode($path, $dec)) { @unlink($dec); return $fail('JPEG XL ilegível'); }
|
||||||
|
$info = @getimagesize($dec);
|
||||||
|
if (!$info) { @unlink($dec); return $fail('JPEG XL ilegível'); }
|
||||||
|
[$w, $h] = $info;
|
||||||
|
if ($w * $h > 4_000_000) @ini_set('memory_limit', '512M');
|
||||||
|
$src = @imagecreatefrompng($dec);
|
||||||
|
if (!$src) { @unlink($dec); return $fail('JPEG XL ilegível'); }
|
||||||
|
|
||||||
|
$scale = min(1, $max_dim / max($w, $h, 1));
|
||||||
|
$nw = max(1, (int) round($w * $scale));
|
||||||
|
$nh = max(1, (int) round($h * $scale));
|
||||||
|
if ($scale < 1) {
|
||||||
|
$dst = imagecreatetruecolor($nw, $nh);
|
||||||
|
imagealphablending($dst, false);
|
||||||
|
imagesavealpha($dst, true);
|
||||||
|
imagefilledrectangle($dst, 0, 0, $nw, $nh, imagecolorallocatealpha($dst, 0, 0, 0, 127));
|
||||||
|
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
|
||||||
|
imagedestroy($src);
|
||||||
|
$src = $dst;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: WebP keeps transparency; without WebP in GD, JPEG.
|
||||||
|
img_fallback_follow($path);
|
||||||
|
if (function_exists('imagewebp')) {
|
||||||
|
imagesavealpha($src, true);
|
||||||
|
$ok = imagewebp($src, $path . '.webp', IMG_WEBP_QUALITY);
|
||||||
|
} else {
|
||||||
|
imageinterlace($src, true);
|
||||||
|
$ok = imagejpeg($src, $path . '.jpg', IMG_JPEG_QUALITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smaller JXL: re-encode from the resized pixels when there is an encoder.
|
||||||
|
$saved = 0;
|
||||||
|
if ($ok && $scale < 1 && imagepng($src, $dec)) {
|
||||||
|
$tmp = $path . '.opt.tmp';
|
||||||
|
$before = (int) filesize($path);
|
||||||
|
if (img_jxl_encode($dec, $tmp) && filesize($tmp) < $before) {
|
||||||
|
if (!@rename($tmp, $path)) { @unlink($path); @rename($tmp, $path); }
|
||||||
|
clearstatcache(true, $path);
|
||||||
|
$saved = max(0, $before - (int) filesize($path));
|
||||||
|
}
|
||||||
|
@unlink($tmp);
|
||||||
|
}
|
||||||
|
imagedestroy($src);
|
||||||
|
@unlink($dec);
|
||||||
|
if (!$ok) return $fail('falha ao gravar a versão alternativa');
|
||||||
|
// The fallback must not look older than the .jxl (idempotence check above).
|
||||||
|
@touch(img_jxl_fallback($path));
|
||||||
|
return ['status' => 'resized', 'saved' => $saved,
|
||||||
|
'reason' => ($scale < 1 ? "{$w}×{$h} → {$nw}×{$nh}, " : '') . 'versão alternativa criada'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Servir imagens (JPEG XL com alternativa) ────────────────────────────────
|
||||||
|
// $rel é o caminho relativo à raiz do projeto (o que a BD guarda / o glob dá).
|
||||||
|
|
||||||
|
function _img_abs(string $rel): string {
|
||||||
|
return dirname(__DIR__) . '/' . ltrim($rel, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback of a .jxl as a project-relative path, or '' (not JXL / no fallback).
|
||||||
|
function img_fallback_rel(string $rel): string {
|
||||||
|
if (!preg_match('/\.jxl$/i', $rel)) return '';
|
||||||
|
$fb = img_jxl_fallback(_img_abs($rel));
|
||||||
|
return $fb === '' ? '' : $rel . substr($fb, -strlen(strrchr($fb, '.')));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where only one image fits (lightbox, og:image, preload, e-mail): the
|
||||||
|
// fallback of a .jxl when there is one, else the file itself.
|
||||||
|
function img_compat_rel(string $rel): string {
|
||||||
|
return img_fallback_rel($rel) ?: $rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// <img> for $rel; a .jxl with a fallback becomes <picture> with the JXL source
|
||||||
|
// first. $attrs: extra <img> attributes (trusted names, values escaped here);
|
||||||
|
// width/height come from the file (or its fallback) unless given.
|
||||||
|
function img_tag(string $rel, array $attrs = []): string {
|
||||||
|
$fb = img_fallback_rel($rel);
|
||||||
|
$src = $fb ?: $rel;
|
||||||
|
if (!isset($attrs['width'])) {
|
||||||
|
$d = @getimagesize(_img_abs($src));
|
||||||
|
if ($d) { $attrs['width'] = $d[0]; $attrs['height'] = $d[1]; }
|
||||||
|
}
|
||||||
|
$a = '';
|
||||||
|
foreach ($attrs as $k => $v) {
|
||||||
|
if ($v === null || $v === false) continue;
|
||||||
|
$a .= ' ' . $k . ($v === true ? '' : '="' . htmlspecialchars((string) $v) . '"');
|
||||||
|
}
|
||||||
|
$img = '<img src="' . htmlspecialchars(base_url($src)) . '"' . $a . '>';
|
||||||
|
if ($fb === '') return $img;
|
||||||
|
return '<picture><source type="image/jxl" srcset="' . htmlspecialchars(base_url($rel)) . '">' . $img . '</picture>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS for a style="" attribute: url(fallback) first, then image-set() with the
|
||||||
|
// JXL, so browsers without image-set()/JXL keep the fallback.
|
||||||
|
function img_css_bg(string $rel): string {
|
||||||
|
$q = static fn(string $r) => "url('" . str_replace(["'", '\\'], ['%27', '%5C'], base_url($r)) . "')";
|
||||||
|
$fb = img_fallback_rel($rel);
|
||||||
|
if ($fb === '') return 'background-image:' . $q($rel);
|
||||||
|
$type = str_ends_with(strtolower($fb), '.webp') ? 'image/webp' : 'image/jpeg';
|
||||||
|
return 'background-image:' . $q($fb) . ';background-image:image-set(' . $q($rel) . ' type("image/jxl"), ' . $q($fb) . ' type("' . $type . '"))';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photos directly inside a project-relative folder ("uploads/gallery/3/"),
|
||||||
|
// sorted, without the .jxl fallbacks.
|
||||||
|
function img_list_dir(string $dir_rel): array {
|
||||||
|
$dir_rel = rtrim($dir_rel, '/') . '/';
|
||||||
|
$out = [];
|
||||||
|
foreach (glob(_img_abs($dir_rel) . '*') ?: [] as $p) {
|
||||||
|
$name = basename($p);
|
||||||
|
if (!is_file($p) || img_is_jxl_fallback($name) || !preg_match('/\.(jpe?g|png|gif|webp|jxl)$/i', $name)) continue;
|
||||||
|
$out[] = $dir_rel . $name;
|
||||||
|
}
|
||||||
|
sort($out);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
@@ -39,8 +39,7 @@ function site_section_has_content(array $gym, string $sec): bool {
|
|||||||
'equipa' => (bool) site_team_active($pdo, $gid),
|
'equipa' => (bool) site_team_active($pdo, $gid),
|
||||||
'horario' => (bool) site_horario($pdo, '', $gid),
|
'horario' => (bool) site_horario($pdo, '', $gid),
|
||||||
'mensalidades' => (bool) site_memberships($pdo, $gid),
|
'mensalidades' => (bool) site_memberships($pdo, $gid),
|
||||||
'galeria' => !empty($gym['gallery_id']) && preg_grep('/\.(jpe?g|png|gif|webp|jxl)$/i',
|
'galeria' => !empty($gym['gallery_id']) && img_list_dir('uploads/gallery/' . (int) $gym['gallery_id']),
|
||||||
glob(dirname(__DIR__) . '/uploads/gallery/' . (int) $gym['gallery_id'] . '/*') ?: []),
|
|
||||||
default => true,
|
default => true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,9 +269,12 @@ function _trash_image_file(PDO $pdo, int $imageId, string $action): void {
|
|||||||
if ($action === 'delete' && is_file($live)) {
|
if ($action === 'delete' && is_file($live)) {
|
||||||
if (!is_dir($dir . '.trash')) @mkdir($dir . '.trash', 0775, true);
|
if (!is_dir($dir . '.trash')) @mkdir($dir . '.trash', 0775, true);
|
||||||
@rename($live, $trash);
|
@rename($live, $trash);
|
||||||
|
img_fallback_follow($live, $trash); // foto.jxl.webp goes with foto.jxl
|
||||||
} elseif ($action === 'restore' && is_file($trash)) {
|
} elseif ($action === 'restore' && is_file($trash)) {
|
||||||
@rename($trash, $live);
|
@rename($trash, $live);
|
||||||
|
img_fallback_follow($trash, $live);
|
||||||
} elseif ($action === 'purge') {
|
} elseif ($action === 'purge') {
|
||||||
if (is_file($trash)) @unlink($trash);
|
if (is_file($trash)) @unlink($trash);
|
||||||
|
img_fallback_follow($trash);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4456,6 +4456,8 @@ h3.contact-title {
|
|||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* .jxl images are <picture> (img_tag): let the <img> size against its parent as before. */
|
||||||
|
.gal-link picture, .navbar-brand picture, .gym-switch-btn picture { display: contents; }
|
||||||
.gal-link img {
|
.gal-link img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -722,7 +722,8 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
function show(i) {
|
function show(i) {
|
||||||
current = (i + links.length) % links.length;
|
current = (i + links.length) % links.length;
|
||||||
var src = links[current].querySelector('img');
|
var src = links[current].querySelector('img');
|
||||||
img.src = links[current].getAttribute('href');
|
// The tile's chosen source (JXL inside <picture> where supported), else the link (fallback).
|
||||||
|
img.src = (src && src.currentSrc) || links[current].getAttribute('href');
|
||||||
img.alt = src ? src.alt : '';
|
img.alt = src ? src.alt : '';
|
||||||
// Restart the fade-in for each photo.
|
// Restart the fade-in for each photo.
|
||||||
img.style.animation = 'none'; void img.offsetWidth; img.style.animation = '';
|
img.style.animation = 'none'; void img.offsetWidth; img.style.animation = '';
|
||||||
|
|||||||
@@ -6,9 +6,10 @@
|
|||||||
// tile (without dragging) opens it full size in a <dialog> lightbox with prev /
|
// tile (without dragging) opens it full size in a <dialog> lightbox with prev /
|
||||||
// next, arrow keys, Esc and swipe; the repeated copies are hidden from assistive
|
// next, arrow keys, Esc and swipe; the repeated copies are hidden from assistive
|
||||||
// tech and open the same photo.
|
// tech and open the same photo.
|
||||||
// Every format the admin upload accepts (functions_galerias.php), not just .jpg.
|
// Every format the admin upload accepts (functions_galerias.php), without the
|
||||||
$files = preg_grep('/\.(jpe?g|png|gif|webp|jxl)$/i', glob(rtrim($gallery_path, '/') . '/*') ?: []);
|
// .jxl.webp/.jpg fallbacks; a .jxl tile is <picture> (img_tag), the link goes to
|
||||||
$files = array_values($files ?: []);
|
// the fallback and the lightbox shows whichever the tile picked (currentSrc).
|
||||||
|
$files = img_list_dir($gallery_path);
|
||||||
// Column span per tile, cycled: w = two columns, s = one.
|
// Column span per tile, cycled: w = two columns, s = one.
|
||||||
$gal_shapes = ['w', 's', 's', 's', 's', 'w', 's', 's', 's', 's', 's'];
|
$gal_shapes = ['w', 's', 's', 's', 's', 'w', 's', 's', 's', 's', 's'];
|
||||||
$gal_label = $gallery_h2 ?? 'Instalações';
|
$gal_label = $gallery_h2 ?? 'Instalações';
|
||||||
@@ -45,15 +46,12 @@ $gal_label = $gallery_h2 ?? 'Instalações';
|
|||||||
<div class="gal-unit <?= $u['grid'] ? 'gal-unit--grid' : 'gal-unit--full' ?>">
|
<div class="gal-unit <?= $u['grid'] ? 'gal-unit--grid' : 'gal-unit--full' ?>">
|
||||||
<?php foreach ($u['idx'] as $gi):
|
<?php foreach ($u['idx'] as $gi):
|
||||||
$f = $files[$gi];
|
$f = $files[$gi];
|
||||||
$src = base_url($f);
|
$src = base_url(img_compat_rel($f));
|
||||||
$alt = $gal_label . ' — FitMe 3070, foto ' . ($gi + 1); // distinct alt per photo
|
$alt = $gal_label . ' — FitMe 3070, foto ' . ($gi + 1); // distinct alt per photo
|
||||||
$dim = @getimagesize($f) ?: [0, 0];
|
|
||||||
?>
|
?>
|
||||||
<div class="gal-tile">
|
<div class="gal-tile">
|
||||||
<a href="<?= htmlspecialchars($src) ?>" class="gal-link" data-index="<?= $gi ?>"<?= $dup ? ' data-dup="1" tabindex="-1"' : '' ?> aria-label="Ampliar: <?= htmlspecialchars($alt) ?>">
|
<a href="<?= htmlspecialchars($src) ?>" class="gal-link" data-index="<?= $gi ?>"<?= $dup ? ' data-dup="1" tabindex="-1"' : '' ?> aria-label="Ampliar: <?= htmlspecialchars($alt) ?>">
|
||||||
<img src="<?= htmlspecialchars($src) ?>" alt="<?= $dup ? '' : htmlspecialchars($alt) ?>"
|
<?= img_tag($f, ['alt' => $dup ? '' : $alt, 'loading' => $gi > 2 || $dup ? 'lazy' : null, 'decoding' => 'async', 'draggable' => 'false']) ?>
|
||||||
<?= $dim[0] ? 'width="' . (int) $dim[0] . '" height="' . (int) $dim[1] . '"' : '' ?>
|
|
||||||
<?= $gi > 2 || $dup ? 'loading="lazy"' : '' ?> decoding="async" draggable="false">
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ $canonical = $GLOBALS['_canonical_override'] ?? match ($page) {
|
|||||||
<link rel="dns-prefetch" href="https://forms.zohopublic.eu">
|
<link rel="dns-prefetch" href="https://forms.zohopublic.eu">
|
||||||
<?php if (($current_uri_2 ?? '') === ''): ?>
|
<?php if (($current_uri_2 ?? '') === ''): ?>
|
||||||
<!-- Homepage hero is the LCP image — start fetching it before the CSS/JS -->
|
<!-- Homepage hero is the LCP image — start fetching it before the CSS/JS -->
|
||||||
<link rel="preload" as="image" fetchpriority="high" href="<?= htmlspecialchars(base_url(($gym_meta['hero_image'] ?? '') !== '' ? $gym_meta['hero_image'] : 'site/assets/img/home.jpg')) ?>">
|
<?php $pre_hero = ($gym_meta['hero_image'] ?? '') !== '' ? $gym_meta['hero_image'] : 'site/assets/img/home.jpg';
|
||||||
|
// A .jxl hero (img_css_bg) is preloaded only where JXL is supported (type=); the rest fetch the fallback from the CSS. ?>
|
||||||
|
<link rel="preload" as="image" fetchpriority="high" href="<?= htmlspecialchars(base_url($pre_hero)) ?>"<?= img_fallback_rel($pre_hero) !== '' ? ' type="image/jxl"' : '' ?>>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Fonts (Roboto self-hosted — see @font-face in style.css) -->
|
<!-- Fonts (Roboto self-hosted — see @font-face in style.css) -->
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
|
|||||||
<header class="header-section lazy">
|
<header class="header-section lazy">
|
||||||
<nav class="navbar navbar-expand-lg navbar-light" style="padding-left:1rem; padding-top: 0; padding-bottom: 0;padding-right: 0; ">
|
<nav class="navbar navbar-expand-lg navbar-light" style="padding-left:1rem; padding-top: 0; padding-bottom: 0;padding-right: 0; ">
|
||||||
<a class="navbar-brand" href="<?= htmlspecialchars(gym_url(current_gym())) ?>">
|
<a class="navbar-brand" href="<?= htmlspecialchars(gym_url(current_gym())) ?>">
|
||||||
|
<?php if (img_fallback_rel($logo_rel) !== ''): ?>
|
||||||
|
<?= img_tag($logo_rel, ['loading' => 'eager', 'decoding' => 'async', 'alt' => 'Logo ' . (current_gym()['name'] ?? 'FitMe 3070'), 'style' => 'width: 150px; height: auto;']) ?>
|
||||||
|
<?php else: ?>
|
||||||
<img loading="eager" decoding="async" alt="Logo <?= htmlspecialchars(current_gym()['name'] ?? 'FitMe 3070') ?>" src="<?= htmlspecialchars($logo) ?>" <?= $logo_dims ?> style="width: 150px; height: auto;">
|
<img loading="eager" decoding="async" alt="Logo <?= htmlspecialchars(current_gym()['name'] ?? 'FitMe 3070') ?>" src="<?= htmlspecialchars($logo) ?>" <?= $logo_dims ?> style="width: 150px; height: auto;">
|
||||||
|
<?php endif; ?>
|
||||||
</a>
|
</a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button>
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button>
|
||||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
@@ -63,7 +67,7 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
|
|||||||
<?php $g_logo = gym_logo($g); ?>
|
<?php $g_logo = gym_logo($g); ?>
|
||||||
<a class="gym-switch-btn<?= $g_logo !== '' ? ' gym-switch-btn--logo' : '' ?>" href="<?= htmlspecialchars(gym_url($g)) ?>" style="<?= htmlspecialchars(gym_brand_vars($g)) ?>">
|
<a class="gym-switch-btn<?= $g_logo !== '' ? ' gym-switch-btn--logo' : '' ?>" href="<?= htmlspecialchars(gym_url($g)) ?>" style="<?= htmlspecialchars(gym_brand_vars($g)) ?>">
|
||||||
<?php if ($g_logo !== ''): ?>
|
<?php if ($g_logo !== ''): ?>
|
||||||
<img class="gym-switch-logo" src="<?= htmlspecialchars(base_url($g_logo)) ?>" alt="<?= htmlspecialchars($g['name']) ?>" decoding="async">
|
<?= img_tag($g_logo, ['class' => 'gym-switch-logo', 'alt' => $g['name'], 'decoding' => 'async', 'width' => null]) ?>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span><?= htmlspecialchars($g['name']) ?></span>
|
<span><?= htmlspecialchars($g['name']) ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ $ld = [
|
|||||||
'@type' => 'ExerciseGym',
|
'@type' => 'ExerciseGym',
|
||||||
'name' => $ld_gym['name'] !== '' ? $ld_gym['name'] : (($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070'),
|
'name' => $ld_gym['name'] !== '' ? $ld_gym['name'] : (($info['name'] ?? '') !== '' ? $info['name'] : 'FitMe 3070'),
|
||||||
'url' => gym_url($ld_gym),
|
'url' => gym_url($ld_gym),
|
||||||
'image' => base_url(($ld_gym['hero_image'] ?? '') !== '' ? $ld_gym['hero_image'] : 'site/assets/img/home.jpg'),
|
'image' => base_url(img_compat_rel(($ld_gym['hero_image'] ?? '') !== '' ? $ld_gym['hero_image'] : 'site/assets/img/home.jpg')),
|
||||||
'logo' => base_url('site/assets/img/logos/logo_black.svg'),
|
'logo' => base_url('site/assets/img/logos/logo_black.svg'),
|
||||||
];
|
];
|
||||||
if (!empty($info['phone'])) $ld['telephone'] = site_tel($info['phone']);
|
if (!empty($info['phone'])) $ld['telephone'] = site_tel($info['phone']);
|
||||||
|
|||||||
+2
-2
@@ -51,7 +51,7 @@ $hero_img = $gym['hero_image'] !== '' ? $gym['hero_image'] : 'site/assets/img
|
|||||||
|
|
||||||
<!-- Hero background is inline (no data-setbg) so it paints without waiting for JS;
|
<!-- Hero background is inline (no data-setbg) so it paints without waiting for JS;
|
||||||
the set-bg class is kept for its cover/center styling. -->
|
the set-bg class is kept for its cover/center styling. -->
|
||||||
<section id="home" class="hero-section set-bg fade-in" style="background-image:url('<?= htmlspecialchars(base_url($hero_img), ENT_QUOTES) ?>')">
|
<section id="home" class="hero-section set-bg fade-in" style="<?= htmlspecialchars(img_css_bg($hero_img), ENT_QUOTES) ?>">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="hero-text">
|
<div class="hero-text">
|
||||||
<?php /* Eventos a decorrer — destacados no topo do header. Cada um liga
|
<?php /* Eventos a decorrer — destacados no topo do header. Cada um liga
|
||||||
@@ -117,7 +117,7 @@ if ($offer):
|
|||||||
<?php
|
<?php
|
||||||
// ── Galeria (Instalações) ───────────────────────────────────────────────────────
|
// ── Galeria (Instalações) ───────────────────────────────────────────────────────
|
||||||
$gallery_path = !empty($gym['gallery_id']) ? 'uploads/gallery/' . (int) $gym['gallery_id'] . '/' : '';
|
$gallery_path = !empty($gym['gallery_id']) ? 'uploads/gallery/' . (int) $gym['gallery_id'] . '/' : '';
|
||||||
$gallery_files = ($on('galeria') && $gallery_path !== '') ? preg_grep('/\.(jpe?g|png|gif|webp|jxl)$/i', glob($gallery_path . '*') ?: []) : [];
|
$gallery_files = ($on('galeria') && $gallery_path !== '') ? img_list_dir($gallery_path) : [];
|
||||||
if ($gallery_files && count($gallery_files) > 0):
|
if ($gallery_files && count($gallery_files) > 0):
|
||||||
$gallery_h2 = 'Conhece as nossas instalações';
|
$gallery_h2 = 'Conhece as nossas instalações';
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
|
|||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label" for="hero_file">Imagem do header</label>
|
<label class="form-label" for="hero_file">Imagem do header</label>
|
||||||
<?php if (($data['hero_image'] ?? '') !== ''): ?>
|
<?php if (($data['hero_image'] ?? '') !== ''): ?>
|
||||||
<div class="mb-2"><img src="<?= htmlspecialchars(base_url($data['hero_image'])) ?>" alt="" class="rounded border" style="max-width:100%;max-height:110px;object-fit:cover"></div>
|
<div class="mb-2"><img src="<?= htmlspecialchars(base_url(img_compat_rel($data['hero_image']))) ?>" alt="" class="rounded border" style="max-width:100%;max-height:110px;object-fit:cover"></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<input type="hidden" name="hero_image" value="<?= htmlspecialchars($data['hero_image'] ?? '') ?>">
|
<input type="hidden" name="hero_image" value="<?= htmlspecialchars($data['hero_image'] ?? '') ?>">
|
||||||
<input type="file" class="form-control" id="hero_file" name="hero_file" accept="image/jpeg,image/png,image/webp,image/jxl,.jxl">
|
<input type="file" class="form-control" id="hero_file" name="hero_file" accept="image/jpeg,image/png,image/webp,image/jxl,.jxl">
|
||||||
|
|||||||
Reference in new issue
Block a user