diff --git a/CLAUDE.md b/CLAUDE.md index 70138fc..12fe414 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). ## 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. - **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)` (``, plain `` 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. diff --git a/config/functions_galerias.php b/config/functions_galerias.php index 491b647..e6b4bda 100644 --- a/config/functions_galerias.php +++ b/config/functions_galerias.php @@ -50,19 +50,30 @@ function db_gallery_images(PDO $pdo, int $gallery_id): array { return $stmt->fetchAll(PDO::FETCH_ASSOC); } -function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file): ?string { - if ($file['error'] !== UPLOAD_ERR_OK) return null; +// Limite por ficheiro: o menor entre 16 MB e o upload_max_filesize do PHP. +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). $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 . '/'; if (!is_dir($upload_dir)) @mkdir($upload_dir, 0775, true); $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); $stmt = $pdo->prepare("INSERT INTO gallery (i_file_name, i_file_type, i_project_id) VALUES (?, ?, ?)"); diff --git a/views/admin/galerias/edit.php b/views/admin/galerias/edit.php index 52fae40..2bbdce9 100644 --- a/views/admin/galerias/edit.php +++ b/views/admin/galerias/edit.php @@ -25,12 +25,29 @@ if (isset($_POST['rename'])) { } } -// Upload image -if (isset($_FILES['image']) && $_FILES['image']['error'] !== UPLOAD_ERR_NO_FILE) { +// Upload images (several at once: images[]) +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['upload'])) { csrf_check(); - $result = db_gallery_image_upload($pdo, $id, $_FILES['image']); - if ($result) session()->setFlashdata('success', 'Imagem carregada.'); - else session()->setFlashdata('error', 'Falha no upload. Verifique o formato.'); + $files = []; + foreach ((array) ($_FILES['images']['name'] ?? []) as $i => $n) { + 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)); exit; } @@ -78,14 +95,14 @@ include __DIR__ . '/../../../includes/header.php';
-

Adicionar imagem

+

Adicionar imagens

- -
JPG, PNG, GIF, WebP ou JPEG XL (o JPEG XL não é redimensionado e só aparece em browsers que o suportam, como o Safari).
+ +
Pode escolher várias. JPG, PNG, GIF, WebP ou JPEG XL, até MB cada (máx. de cada vez, no total).
- +
@@ -110,9 +127,19 @@ include __DIR__ . '/../../../includes/header.php';
+
- + + +
+ + JPEG XL + +
+ + '', 'class' => 'img-thumbnail w-100', 'style' => 'height:140px;object-fit:cover;', 'loading' => 'lazy', 'width' => null]) ?> +