release: promote beta to main (gyms, brand colours, JPEG XL, SEO per gym) #3

Merged
andre merged 74 commits from beta into main 2026-10-26 19:47:48 +00:00
3 changed files with 55 additions and 17 deletions
Showing only changes of commit 6ac5213624 - Show all commits

No files matched your search

+1 -1
View File
@@ -89,7 +89,7 @@ An unpublished gym 404s publicly but previews for staff.
- YourFit modalidades use their brand glyphs: `fitme-yourfit-*.svg` were traced (2026-10-26) from the left tile of the wordmarks `uploads/modalidades/icons/yourfit-*.png` (150×33, the only source found) into one even-odd `currentColor` path, so they keep the CSS colour/hover and the transform animations (no stroke draw). - YourFit modalidades use their brand glyphs: `fitme-yourfit-*.svg` were traced (2026-10-26) from the left tile of the wordmarks `uploads/modalidades/icons/yourfit-*.png` (150×33, the only source found) into one even-odd `currentColor` path, so they keep the CSS colour/hover and the transform animations (no stroke draw).
## 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). 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). The gallery edit page (`views/admin/galerias/edit.php`) takes several files at once (`images[]`, one flash with the count plus each refused file and why, from `db_gallery_image_upload(..., &$error)`; limits shown from `gallery_upload_max_bytes()` / `max_file_uploads` / `post_max_size`, and a POST over `post_max_size` gets its own message). A `.jxl` without fallback shows as a «JPEG XL» placeholder tile with its filename (Chrome can't draw it); with a fallback it uses `img_tag()`.
- **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. - **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. 2026-10-26: added to the VPS `/srv/stacks/fitme.andmore.pt/Dockerfile` apt line (backup `Dockerfile.bak-20261026`), active only after `docker compose build && docker compose up -d` there (the user runs it); on dev it is installed by hand in the running container (`apt-get install libjxl-tools` as root) and is lost when the TrueNAS app recreates it. Until then `.jxl` uploads have no fallback and show only in browsers with JXL (Safari). After installing, press «Otimizar» on each gallery to backfill. - **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. 2026-10-26: added to the VPS `/srv/stacks/fitme.andmore.pt/Dockerfile` apt line (backup `Dockerfile.bak-20261026`), active only after `docker compose build && docker compose up -d` there (the user runs it); on dev it is installed by hand in the running container (`apt-get install libjxl-tools` as root) and is lost when the TrueNAS app recreates it. 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. - **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.
+16 -5
View File
@@ -50,19 +50,30 @@ function db_gallery_images(PDO $pdo, int $gallery_id): array {
return $stmt->fetchAll(PDO::FETCH_ASSOC); return $stmt->fetchAll(PDO::FETCH_ASSOC);
} }
function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file): ?string { // Limite por ficheiro: o menor entre 16 MB e o upload_max_filesize do PHP.
if ($file['error'] !== UPLOAD_ERR_OK) return null; function gallery_upload_max_bytes(): int {
$ini = trim((string) ini_get('upload_max_filesize'));
$n = (int) $ini;
$n *= match (strtolower(substr($ini, -1))) { 'g' => 1 << 30, 'm' => 1 << 20, 'k' => 1 << 10, default => 1 };
return $n > 0 ? min($n, 16 * 1024 * 1024) : 16 * 1024 * 1024;
}
if (($file['size'] ?? 0) > 16 * 1024 * 1024) return null; // $error recebe o motivo (para a mensagem ao utilizador) quando devolve null.
function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file, ?string &$error = null): ?string {
$max_mb = round(gallery_upload_max_bytes() / 1048576);
if ($file['error'] === UPLOAD_ERR_INI_SIZE || $file['error'] === UPLOAD_ERR_FORM_SIZE) { $error = "maior que {$max_mb} MB"; return null; }
if ($file['error'] !== UPLOAD_ERR_OK) { $error = 'o envio falhou'; return null; }
if (($file['size'] ?? 0) > gallery_upload_max_bytes()) { $error = "maior que {$max_mb} MB"; return null; }
// Tipo decidido pelo conteúdo, não pelo nome (config/functions_images.php). // Tipo decidido pelo conteúdo, não pelo nome (config/functions_images.php).
$ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl']); $ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl']);
if ($ext === null) return null; if ($ext === null) { $error = 'formato não suportado'; return null; }
$upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/'; $upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/';
if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true); if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true);
$filename = bin2hex(random_bytes(16)) . '.' . $ext; $filename = bin2hex(random_bytes(16)) . '.' . $ext;
if (!move_uploaded_file($file['tmp_name'], $upload_dir . $filename)) return null; if (!move_uploaded_file($file['tmp_name'], $upload_dir . $filename)) { $error = 'não foi possível gravar'; return null; }
img_optimize($upload_dir . $filename, IMG_MAX_GALLERY); img_optimize($upload_dir . $filename, IMG_MAX_GALLERY);
$stmt = $pdo->prepare("INSERT INTO gallery (i_file_name, i_file_type, i_project_id) VALUES (?, ?, ?)"); $stmt = $pdo->prepare("INSERT INTO gallery (i_file_name, i_file_type, i_project_id) VALUES (?, ?, ?)");
+38 -11
View File
@@ -25,12 +25,29 @@ if (isset($_POST['rename'])) {
} }
} }
// Upload image // Upload images (several at once: images[])
if (isset($_FILES['image']) && $_FILES['image']['error'] !== UPLOAD_ERR_NO_FILE) { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['upload'])) {
csrf_check(); csrf_check();
$result = db_gallery_image_upload($pdo, $id, $_FILES['image']); $files = [];
if ($result) session()->setFlashdata('success', 'Imagem carregada.'); foreach ((array) ($_FILES['images']['name'] ?? []) as $i => $n) {
else session()->setFlashdata('error', 'Falha no upload. Verifique o formato.'); if ($_FILES['images']['error'][$i] === UPLOAD_ERR_NO_FILE) continue;
$files[] = ['name' => $n, 'type' => $_FILES['images']['type'][$i], 'tmp_name' => $_FILES['images']['tmp_name'][$i],
'error' => $_FILES['images']['error'][$i], 'size' => $_FILES['images']['size'][$i]];
}
$ok = 0; $failed = [];
foreach ($files as $file) {
if (db_gallery_image_upload($pdo, $id, $file, $why)) $ok++;
else $failed[] = $file['name'] . ' (' . $why . ')';
}
if ($ok) session()->setFlashdata('success', $ok === 1 ? 'Imagem carregada.' : "$ok imagens carregadas.");
if ($failed) session()->setFlashdata($ok ? 'warning' : 'error', 'Não carregadas: ' . implode(', ', $failed) . '.');
if (!$files) session()->setFlashdata('error', 'Escolha pelo menos uma imagem.');
header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id));
exit;
}
// Over post_max_size PHP drops the whole body ($_POST and $_FILES empty), so say so instead of doing nothing.
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$_POST && (int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 0) {
session()->setFlashdata('error', 'O envio é maior do que o servidor aceita (' . ini_get('post_max_size') . ' no total). Carregue menos imagens de cada vez.');
header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id)); header('Location: ' . base_url('views/admin/galerias/edit.php?id=' . $id));
exit; exit;
} }
@@ -78,14 +95,14 @@ include __DIR__ . '/../../../includes/header.php';
</div></div> </div></div>
<div class="card shadow-sm mt-3"><div class="card-body"> <div class="card shadow-sm mt-3"><div class="card-body">
<h2 class="h6 mb-3">Adicionar imagem</h2> <h2 class="h6 mb-3">Adicionar imagens</h2>
<form method="POST" enctype="multipart/form-data"> <form method="POST" enctype="multipart/form-data">
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="mb-3"> <div class="mb-3">
<input type="file" class="form-control" name="image" accept=".jpg,.jpeg,.png,.gif,.webp,.jxl,image/jxl"> <input type="file" class="form-control" name="images[]" multiple accept=".jpg,.jpeg,.png,.gif,.webp,.jxl,image/jpeg,image/png,image/gif,image/webp,image/jxl">
<div class="form-text">JPG, PNG, GIF, WebP ou JPEG XL (o JPEG XL não é redimensionado e só aparece em browsers que o suportam, como o Safari).</div> <div class="form-text">Pode escolher várias. JPG, PNG, GIF, WebP ou JPEG XL, até <?= round(gallery_upload_max_bytes() / 1048576) ?> MB cada (máx. <?= (int) ini_get('max_file_uploads') ?> de cada vez, <?= htmlspecialchars((string) ini_get('post_max_size')) ?> no total).</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-cloud-arrow-up me-1"></i>Carregar</button> <button type="submit" name="upload" value="1" class="btn btn-primary btn-sm"><i class="bi bi-cloud-arrow-up me-1"></i>Carregar</button>
</form> </form>
</div></div> </div></div>
</div> </div>
@@ -110,9 +127,19 @@ include __DIR__ . '/../../../includes/header.php';
<?php else: ?> <?php else: ?>
<div class="row g-2"> <div class="row g-2">
<?php foreach ($images as $img): ?> <?php foreach ($images as $img): ?>
<?php $rel = 'uploads/gallery/' . $id . '/' . $img['i_file_name']; ?>
<div class="col-6 col-lg-4 position-relative"> <div class="col-6 col-lg-4 position-relative">
<img src="<?= base_url('uploads/gallery/' . $id . '/' . $img['i_file_name']) ?>" <?php if (str_ends_with(strtolower($rel), '.jxl') && img_fallback_rel($rel) === ''): ?>
alt="" class="img-thumbnail w-100" style="height:140px;object-fit:cover;" loading="lazy"> <?php /* JPEG XL still without its WebP/JPG copy (no decoder on the server yet): most browsers
can't draw it, so show what is stored instead of a blank tile. */ ?>
<div class="img-thumbnail w-100 d-flex flex-column align-items-center justify-content-center text-center small text-body-secondary" style="height:140px;">
<i class="bi bi-file-earmark-image fs-3"></i>
<span class="badge text-bg-secondary my-1">JPEG XL</span>
<span class="text-truncate w-100 px-1" title="<?= htmlspecialchars($img['i_file_name']) ?>"><?= htmlspecialchars($img['i_file_name']) ?></span>
</div>
<?php else: ?>
<?= img_tag($rel, ['alt' => '', 'class' => 'img-thumbnail w-100', 'style' => 'height:140px;object-fit:cover;', 'loading' => 'lazy', 'width' => null]) ?>
<?php endif; ?>
<form method="POST" class="position-absolute top-0 end-0 m-1" onsubmit="return confirm('Remover esta imagem? Fica na lixeira durante 30 dias.')"> <form method="POST" class="position-absolute top-0 end-0 m-1" onsubmit="return confirm('Remover esta imagem? Fica na lixeira durante 30 dias.')">
<?= csrf_field() ?> <?= csrf_field() ?>
<button class="btn btn-sm btn-danger rounded-circle" name="del_img" value="<?= (int) $img['i_id'] ?>" aria-label="Remover imagem" <button class="btn btn-sm btn-danger rounded-circle" name="del_img" value="<?= (int) $img['i_id'] ?>" aria-label="Remover imagem"