feat(gyms): show gym logos in navbar buttons and brand; accept JPEG XL uploads
This commit is contained in:
1 parent
a85bec6851
commit
d445ba64b9
7 files changed
+51
-18
No files matched your search
@@ -1,6 +1,11 @@
|
||||
# Disable directory listing
|
||||
Options -Indexes
|
||||
|
||||
# JPEG XL uploads (gym header/logo): older mime.types don't know the extension.
|
||||
<IfModule mod_mime.c>
|
||||
AddType image/jxl .jxl
|
||||
</IfModule>
|
||||
|
||||
# Block direct HTTP access to sensitive files
|
||||
<FilesMatch "^\.(env|env\..*|git.*|htaccess|htpasswd)$">
|
||||
Require all denied
|
||||
|
||||
@@ -79,7 +79,7 @@ Since 2026-09-25, one install serves several physical gyms: the `gyms` table, on
|
||||
folder per gym (`/3070/`, `/<postal-code>/`), 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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ───────────
|
||||
|
||||
@@ -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);
|
||||
<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; ">
|
||||
<a class="navbar-brand" href="<?= htmlspecialchars(gym_url(current_gym())) ?>">
|
||||
<img loading="eager" decoding="async" alt="Logo FitMe3070" src="<?= $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;">
|
||||
</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>
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
@@ -59,9 +60,13 @@ $logo_dims = svg_img_dims_attr(dirname(__DIR__, 2) . '/' . $logo_rel);
|
||||
if ($other_gyms): ?>
|
||||
<div class="gym-switch d-flex flex-wrap gap-2 ms-lg-auto py-2 py-lg-0 pe-lg-3">
|
||||
<?php foreach ($other_gyms as $g): ?>
|
||||
<a class="gym-switch-btn" href="<?= htmlspecialchars(gym_url($g)) ?>" style="<?= htmlspecialchars(gym_brand_vars($g)) ?>">
|
||||
<?php if (($g['logo'] ?? '') !== ''): ?><span class="gym-switch-logo" aria-hidden="true" style="--logo:url('<?= htmlspecialchars(base_url($g['logo']), ENT_QUOTES) ?>')"></span><?php endif; ?>
|
||||
<?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)) ?>">
|
||||
<?php if ($g_logo !== ''): ?>
|
||||
<img class="gym-switch-logo" src="<?= htmlspecialchars(base_url($g_logo)) ?>" alt="<?= htmlspecialchars($g['name']) ?>" decoding="async">
|
||||
<?php else: ?>
|
||||
<span><?= htmlspecialchars($g['name']) ?></span>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
|
||||
<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>
|
||||
<?php endif; ?>
|
||||
<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">
|
||||
<input type="file" class="form-control" id="hero_file" name="hero_file" accept="image/jpeg,image/png,image/webp,image/jxl,.jxl">
|
||||
<div class="form-text">JPG, PNG ou WebP (até 6 MB). Vazio = a do ginásio principal.</div>
|
||||
<?php if (($data['hero_image'] ?? '') !== ''): ?>
|
||||
<div class="form-check mt-1"><input class="form-check-input" type="checkbox" name="hero_remove" value="1" id="hero_remove"><label class="form-check-label small" for="hero_remove">Remover a imagem atual</label></div>
|
||||
@@ -99,7 +99,7 @@ foreach (array_keys(gym_sections()) as $k) if (gym_section_on($data, $k)) $secti
|
||||
<div class="mb-2"><img src="<?= htmlspecialchars(base_url($data['logo'])) ?>" alt="" class="rounded border p-2" style="max-width:100%;max-height:70px;background:#e9ecef"></div>
|
||||
<?php endif; ?>
|
||||
<input type="hidden" name="logo" value="<?= htmlspecialchars($data['logo'] ?? '') ?>">
|
||||
<input type="file" class="form-control" id="logo_file" name="logo_file" accept="image/svg+xml,image/png,image/webp,image/jpeg">
|
||||
<input type="file" class="form-control" id="logo_file" name="logo_file" accept="image/svg+xml,image/png,image/webp,image/jpeg,image/jxl,.jxl">
|
||||
<div class="form-text">SVG, PNG, WebP ou JPG (até 6 MB). Opcional. Aparece a uma só cor (a do texto do botão); funciona melhor com SVG de uma cor.</div>
|
||||
<?php if (!_gyms_has_col($pdo, 'gyms', 'logo')): ?>
|
||||
<div class="form-text text-warning">Falta aplicar a migração <code>migration_20261013_gym_brand_color.sql</code> (Sistema → Atualizar plataforma) para guardar o logótipo e a cor da marca.</div>
|
||||
|
||||
Reference in new issue
Block a user