From d445ba64b99dea43452b2e968c71411e19336559 Mon Sep 17 00:00:00 2001 From: andre Date: Mon, 26 Oct 2026 09:35:46 +0000 Subject: [PATCH] feat(gyms): show gym logos in navbar buttons and brand; accept JPEG XL uploads --- .htaccess | 5 +++++ CLAUDE.md | 2 +- config/functions_gyms.php | 16 ++++++++++++---- config/functions_images.php | 9 +++++++++ site/assets/css/style.css | 20 +++++++++++++------- site/includes/navbar.php | 13 +++++++++---- views/admin/gyms/_form.php | 4 ++-- 7 files changed, 51 insertions(+), 18 deletions(-) diff --git a/.htaccess b/.htaccess index ddf23fe..08eaced 100644 --- a/.htaccess +++ b/.htaccess @@ -1,6 +1,11 @@ # Disable directory listing Options -Indexes +# JPEG XL uploads (gym header/logo): older mime.types don't know the extension. + + AddType image/jxl .jxl + + # Block direct HTTP access to sensitive files Require all denied diff --git a/CLAUDE.md b/CLAUDE.md index fd4f47f..9a5ee27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ Since 2026-09-25, one install serves several physical gyms: the `gyms` table, on folder per gym (`/3070/`, `//`), content rows carry `gym_id`, and the backoffice has an "a editar" gym switcher (`admin_gym_id()` / `admin_gym()` in `config/functions_gyms.php`). `/` and `/home` 301 to the default gym (`is_default`); the other published gyms appear as highlighted buttons (`.gym-switch-btn`) at the right end of the public navbar (`site/includes/navbar.php`). `site/gym_chooser.php` is no longer used. -Each gym has a brand colour (`gyms.brand_color`, `#RRGGBB`, migration `migration_20261013_gym_brand_color.sql`; gym 1 = green `#C2D500`, gym 2 = red): set in Gestão → Ginásios, emitted as `:root` overrides by `gym_brand_css()` in `site/includes/include.php`. New components must use `--fitme-accent*` / `--fitme-on-accent`, never a hard-coded colour; the CrossFit nav pill follows the brand colour too. Each gym can have a `logo` (same migration); the header image and the logo are uploaded in Gestão → Ginásios (`gyms_save_image()` → `uploads/gyms/`, validated by content, SVG through `svg_is_safe()`), not typed as paths; `db_gyms_create/update` skip `brand_color`/`logo` while the migration isn't applied (`_gyms_cols()`); the navbar button for another gym is drawn in that gym's brand colour (`gym_brand_vars()` inline) with the logo as a CSS mask in the text colour. +Each gym has a brand colour (`gyms.brand_color`, `#RRGGBB`, migration `migration_20261013_gym_brand_color.sql`; gym 1 = green `#C2D500`, gym 2 = red): set in Gestão → Ginásios, emitted as `:root` overrides by `gym_brand_css()` in `site/includes/include.php`. New components must use `--fitme-accent*` / `--fitme-on-accent`, never a hard-coded colour; the CrossFit nav pill follows the brand colour too. Each gym can have a `logo` (same migration); the header image and the logo are uploaded in Gestão → Ginásios (`gyms_save_image()` → `uploads/gyms/`, validated by content, SVG through `svg_is_safe()`), not typed as paths; `db_gyms_create/update` skip `brand_color`/`logo` while the migration isn't applied (`_gyms_cols()`); the navbar button for another gym is drawn in that gym's brand colour (`gym_brand_vars()` inline); with a logo (`gym_logo()`: the gym's own, or for the default gym the bundled `logo_black.svg`) the button shows the logo in full colour on a white face (`.gym-switch-btn--logo`, name as alt), without one it shows the name. The navbar brand on each gym's page uses that gym's logo too. Uploads (header + logo) also accept JPEG XL (`.jxl`, detected by signature in `upload_image_ext()` when the caller allows `'jxl'`; only the gyms form does; `AddType image/jxl` in `.htaccess`; GD can't resize it, so it is stored as uploaded; Chrome/Firefox may not display JXL, Safari does). An unpublished gym 404s publicly but previews for staff. ## platform-tools diff --git a/config/functions_gyms.php b/config/functions_gyms.php index 0ed159f..872d6aa 100644 --- a/config/functions_gyms.php +++ b/config/functions_gyms.php @@ -116,6 +116,14 @@ function gym_brand_vars(array $gym): string { . ';--fitme-accent-ink:' . $shade($lum > .5 ? .5 : .85) . ';--fitme-on-accent:' . ($lum > .5 ? '#0B0B0D' : '#FFFFFF'); } +// The gym's logo (relative path). The main gym falls back to the site's own +// FitMe 3070 logo, so its navbar button has one before anything is uploaded. +function gym_logo(array $gym): string { + $logo = (string) ($gym['logo'] ?? ''); + if ($logo === '' && !empty($gym['is_default'])) $logo = 'site/assets/img/logos/logo_black.svg'; + return $logo; +} + function gym_brand_css(array $gym): string { $v = gym_brand_vars($gym); return $v === '' ? '' : ':root{' . $v . '}'; @@ -205,14 +213,14 @@ function gyms_from_post(PDO $pdo, array $post, array &$errors, ?int $id = null): $other = db_gyms_by_slug($pdo, $slug); if ($other && (int) $other['id'] !== (int) $id) $errors[] = 'Já existe um ginásio com essa pasta da URL.'; } - if ($data['logo'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(svg|png|webp|jpe?g)$~i', $data['logo'])) { + if ($data['logo'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(svg|png|webp|jpe?g|jxl)$~i', $data['logo'])) { $errors[] = 'Logótipo: imagem inválida.'; } if ($data['brand_color'] !== '' && !preg_match('/^#[0-9A-F]{6}$/', $data['brand_color'])) { $errors[] = 'Cor da marca: formato #RRGGBB (ex.: #C2D500).'; } if ($data['is_default'] && !$data['is_published']) $errors[] = 'O ginásio principal tem de estar publicado.'; - if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp)$~i', $data['hero_image'])) { + if ($data['hero_image'] !== '' && !preg_match('~^(site/assets/img|uploads)/[A-Za-z0-9_./ -]+\.(jpe?g|png|webp|jxl)$~i', $data['hero_image'])) { $errors[] = 'Imagem do header: imagem inválida.'; } return $data; @@ -283,10 +291,10 @@ function gyms_save_image(array $file, string $kind, ?string &$error = null): ?st return null; } if (($file['size'] ?? 0) > 6 * 1024 * 1024) { $error = 'máximo 6 MB.'; return null; } - $allowed = $kind === 'hero' ? ['jpg', 'jpeg', 'png', 'webp'] : ['svg', 'png', 'webp', 'jpg', 'jpeg']; + $allowed = $kind === 'hero' ? ['jpg', 'jpeg', 'png', 'webp', 'jxl'] : ['svg', 'png', 'webp', 'jpg', 'jpeg', 'jxl']; $ext = upload_image_ext($file, $allowed); if ($ext === null) { - $error = $kind === 'hero' ? 'usa JPG, PNG ou WebP.' : 'usa SVG, PNG, WebP ou JPG (SVG sem scripts).'; + $error = $kind === 'hero' ? 'usa JPG, PNG, WebP ou JPEG XL.' : 'usa SVG, PNG, WebP, JPG ou JPEG XL (SVG sem scripts).'; return null; } $dir = gyms_upload_dir(); diff --git a/config/functions_images.php b/config/functions_images.php index d3625c7..be90b46 100644 --- a/config/functions_images.php +++ b/config/functions_images.php @@ -39,6 +39,15 @@ function upload_image_ext(array $file, array $allowed): ?string { } $raster = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp']; + // JPEG XL: o getimagesize()/GD não o conhecem e o finfo só nas versões + // recentes, por isso vê-se pela assinatura (codestream FF 0A ou contentor + // ISOBMFF "JXL "). Só entra onde quem chama pôs 'jxl' em $allowed; o + // img_optimize() salta-o (não há GD para JXL). + if (in_array('jxl', $allowed, true)) { + $head = (string) @file_get_contents($tmp, false, null, 0, 12); + if (str_starts_with($head, "\xFF\x0A") || $head === "\x00\x00\x00\x0CJXL \x0D\x0A\x87\x0A") return 'jxl'; + } + if (isset($raster[$mime]) || $mime === '') { $info = @getimagesize($tmp); $ext = $info ? ($raster[$info['mime']] ?? null) : null; diff --git a/site/assets/css/style.css b/site/assets/css/style.css index 67f327b..130ef31 100644 --- a/site/assets/css/style.css +++ b/site/assets/css/style.css @@ -437,14 +437,20 @@ section[id] { .gym-switch-btn { display: inline-flex; align-items: center; gap: .5rem; } -/* The logo is a mask filled with the text colour, so any single-colour logo - follows the button (brand colour, hover state, light or dark brand). */ +/* With a logo the button shows the gym's wordmark in its own colours on a + light face, framed by the brand colour; the name is the image's alt. The + hover face stays light too, so dark parts of the logo never vanish. */ +.gym-switch-btn--logo { + --btn-face: #fff; + background: #fff; + padding: 4px 12px; +} .gym-switch-logo { - height: 1.4em; - aspect-ratio: 1; - background-color: currentColor; - -webkit-mask: var(--logo) center / contain no-repeat; - mask: var(--logo) center / contain no-repeat; + display: block; + height: 30px; + width: auto; + max-width: 130px; + object-fit: contain; } /* ── Button hover: the same spinning edge as the Mensalidades cards ─────────── diff --git a/site/includes/navbar.php b/site/includes/navbar.php index cb695b2..23b25c4 100644 --- a/site/includes/navbar.php +++ b/site/includes/navbar.php @@ -4,7 +4,8 @@ if($current_uri_2 == 'crossfit'){ } else { - $logo_rel = 'site/assets/img/logos/logo_black.svg'; + // Each gym shows its own logo (FitMe In on /mira/, FitMe 3070 on /3070/). + $logo_rel = gym_logo(current_gym()) ?: 'site/assets/img/logos/logo_black.svg'; } $logo = base_url($logo_rel); $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel); @@ -12,7 +13,7 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);