Compare commits

2 Commits
7 changed files with 74 additions and 29 deletions

No files matched your search

+1 -1
View File
@@ -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 folder per gym (`/3070/`, `/<postal-code>/`), content rows carry `gym_id`, and the
backoffice has an "a editar" gym switcher (`admin_gym_id()` / 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. `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. The public navbar's bottom edge is an always-on, slow (14s) sweep in the brand colour (`.header-section::before/::after`, `nav-sweep`, static with reduced motion). Each gym can have a `logo` (same migration, plus `migration_20261026_gym_logo.sql`: the column was added to the 2026-10-13 file after it had already run on dev, so dev had no `logo` column until 2026-10-26 — never edit a migration that has run, add a new one); 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). 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. The public navbar's bottom edge is an always-on, slow (14s) sweep in the brand colour (`.header-section::before/::after`, `nav-sweep`, static with reduced motion). Each gym can have a `logo` (same migration, plus `migration_20261026_gym_logo.sql`: the column was added to the 2026-10-13 file after it had already run on dev, so dev had no `logo` column until 2026-10-26 — never edit a migration that has run, add a new one); 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 straight on the frosted navbar, with no face and no darkening on hover (`.gym-switch-btn--logo`: thin brand outline, hover = spinning ring only, no glow, a light `drop-shadow` halo on the image for contrast; 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'`; the gyms form and gallery photos (`db_gallery_image_upload()`) do; `AddType image/jxl` in `.htaccess`; GD can't resize it, so it is stored as uploaded; Chrome/Firefox may not display JXL, Safari does). The public gallery lists its folder by extension (`site/includes/gallery.php`, `site/index.php`: jpg/jpeg/png/gif/webp/jxl; before 2026-10-26 it only listed `*.jpg`, so PNG/WebP uploads never showed). Gallery photos are not in the scroll "hover" (`.is-centered`, main.js) any more; only a real hover lights them. Mensalidades (`.pricing-section::before`) has a slow (26s) drifting brand-colour glow over the metal texture (`pricing-drift`, static with reduced motion).
An unpublished gym 404s publicly but previews for staff. An unpublished gym 404s publicly but previews for staff.
## platform-tools ## platform-tools
+2 -2
View File
@@ -55,7 +55,7 @@ function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file): ?strin
if (($file['size'] ?? 0) > 16 * 1024 * 1024) return null; if (($file['size'] ?? 0) > 16 * 1024 * 1024) 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']); $ext = upload_image_ext($file, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'jxl']);
if ($ext === null) return null; if ($ext === null) return null;
$upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/'; $upload_dir = __DIR__ . '/../uploads/gallery/' . $gallery_id . '/';
@@ -66,7 +66,7 @@ function db_gallery_image_upload(PDO $pdo, int $gallery_id, array $file): ?strin
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 (?, ?, ?)");
$stmt->execute([$filename, $file['type'] ?? 'image/jpeg', $gallery_id]); $stmt->execute([$filename, $ext === 'jxl' ? 'image/jxl' : ($file['type'] ?? 'image/jpeg'), $gallery_id]);
return $filename; return $filename;
} }
+59 -16
View File
@@ -437,14 +437,7 @@ section[id] {
.gym-switch-btn { display: inline-flex; align-items: center; gap: .5rem; } .gym-switch-btn { display: inline-flex; align-items: center; gap: .5rem; }
/* 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 { .gym-switch-logo {
display: block; display: block;
height: 30px; height: 30px;
@@ -520,6 +513,34 @@ section[id] {
.gym-switch-btn:is(:hover, :focus-visible)::before, .gym-switch-btn:is(:hover, :focus-visible)::after { animation: none; } .gym-switch-btn:is(:hover, :focus-visible)::before, .gym-switch-btn:is(:hover, :focus-visible)::after { animation: none; }
} }
/* With a logo the button shows the gym's wordmark in its own colours straight
on the navbar (no face, like the main logo), framed by a thin brand-colour
outline; the name is the image's alt. On hover nothing fills in behind the
logo: ::before is masked down to just its ring, so only the spinning edge
shows. */
.gym-switch-btn--logo,
.gym-switch-btn--logo:is(:hover, :focus-visible) {
background: transparent;
}
.gym-switch-btn--logo {
padding: 4px 12px;
border-width: 1px;
}
.gym-switch-btn--logo::before {
background: conic-gradient(from var(--pc-a), rgba(var(--fitme-accent-rgb), 0) 0deg, rgba(var(--fitme-accent-rgb), 0) 150deg,
var(--fitme-accent) 250deg, #f4ff9c 300deg, var(--fitme-accent) 330deg,
rgba(var(--fitme-accent-rgb), 0) 360deg) border-box;
-webkit-mask: linear-gradient(#000 0 0) padding-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) padding-box exclude, linear-gradient(#000 0 0);
}
/* No glow either: blurred brand colour behind a dark-red wordmark muddies it.
A thin light halo keeps the logo legible over whatever shows through the
frosted navbar. */
.gym-switch-btn--logo::after,
.gym-switch-btn--logo:is(:hover, :focus-visible)::after { display: none; }
.gym-switch-btn--logo .gym-switch-logo { filter: drop-shadow(0 0 1.5px rgba(255, 255, 255, .9)); }
/* ── Sticky Navbar ─────────────────────────────────────────────────────────── */ /* ── Sticky Navbar ─────────────────────────────────────────────────────────── */
.navbar-toggler { .navbar-toggler {
min-width: 44px; min-width: 44px;
@@ -3836,6 +3857,33 @@ h3.contact-title {
background-image: var(--metal-layers); background-image: var(--metal-layers);
background-size: var(--metal-sizes); background-size: var(--metal-sizes);
color: var(--fitme-ash); color: var(--fitme-ash);
position: relative;
isolation: isolate;
}
/* Slow brand-colour light drifting over the metal: three soft blobs in the
current gym's colour (and a paler tint of it) on a 200% layer whose position
eases back and forth. Sits above the section background, below the content. */
.pricing-section::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(38% 50% at 18% 28%, rgba(var(--fitme-accent-rgb), .4), transparent 70%),
radial-gradient(34% 46% at 82% 72%, color-mix(in srgb, color-mix(in srgb, var(--fitme-accent) 55%, #fff) 26%, transparent), transparent 70%),
radial-gradient(30% 42% at 55% 105%, rgba(var(--fitme-accent-rgb), .32), transparent 70%);
background-size: 200% 200%;
animation: pricing-drift 26s ease-in-out infinite alternate;
}
@keyframes pricing-drift {
0% { background-position: 0% 0%; }
50% { background-position: 100% 40%; }
100% { background-position: 30% 100%; }
}
@media (prefers-reduced-motion: reduce) {
.pricing-section::before { animation: none; }
} }
.pricing-section .section-title h2 { color: #fff; } .pricing-section .section-title h2 { color: #fff; }
@@ -4610,14 +4658,10 @@ h3.contact-title {
} }
/* ── "In the middle of the screen" = hovered ───────────────────────────────────── /* ── "In the middle of the screen" = hovered ─────────────────────────────────────
main.js adds .is-centered to the photo / card / icon that sits on the vertical main.js adds .is-centered to the trainer photo / card / icon that sits on the vertical
middle of the viewport (and, inside the sliding strips, on the horizontal middle of the viewport (and, inside the Modalidades strip, on the horizontal
middle too), so scrolling past shows the same state as a hover. These mirror middle too; the gallery photos only react to a real hover), so scrolling past shows the same state as a hover. These mirror
the :hover rules above. ─────────────────────────────────────────────────── */ the :hover rules above. ─────────────────────────────────────────────────── */
.gal-tile.is-centered .gal-link img { transform: scale(1.06); filter: saturate(1) brightness(1.05); }
.gal-tile.is-centered .gal-link::before { opacity: 1; }
.gal-tile.is-centered .gal-link::after { opacity: 1; animation: pc-spin 3.2s linear infinite; }
.trainer-item .trainer-pic.is-centered, .trainer-item .trainer-pic.is-centered,
#powerRotation>div:nth-child(even) .trainer-pic.is-centered { #powerRotation>div:nth-child(even) .trainer-pic.is-centered {
transform: rotate(0deg) translateY(-8px) scale(1.05); transform: rotate(0deg) translateY(-8px) scale(1.05);
@@ -4658,7 +4702,6 @@ h3.contact-title {
} }
.price-card.is-centered::after { opacity: .75; animation: pc-spin 3.2s linear infinite; } .price-card.is-centered::after { opacity: .75; animation: pc-spin 3.2s linear infinite; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.gal-tile.is-centered .gal-link::after,
.trainer-item .trainer-pic.is-centered, .trainer-item .trainer-pic.is-centered,
.icon-box-item.is-centered .ib-icon::after, .icon-box-item.is-centered .ib-icon::after,
.price-card.is-centered::before, .price-card.is-centered::after { animation: none; } .price-card.is-centered::before, .price-card.is-centered::after { animation: none; }
+6 -5
View File
@@ -759,16 +759,17 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
/* ── Scroll "hover": what sits in the middle of the screen looks hovered ─────── /* ── Scroll "hover": what sits in the middle of the screen looks hovered ───────
Photos, cards and icons get .is-centered while their box crosses the middle Trainer photos, cards and icons get .is-centered while their box crosses the
of the viewport (±12% of its height); inside a sliding strip (gallery, middle of the viewport (±12% of its height); inside a sliding strip
Modalidades) they must also cross the horizontal middle. Throttled, paused (Modalidades) they must also cross the horizontal middle. Gallery photos are
left out: they only light up on a real hover. Throttled, paused
while the tab is hidden, skipped with "reduce motion". The CSS mirrors the while the tab is hidden, skipped with "reduce motion". The CSS mirrors the
:hover rules (style.css, "In the middle of the screen"). */ :hover rules (style.css, "In the middle of the screen"). */
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return; if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var items = Array.prototype.slice.call(document.querySelectorAll( var items = Array.prototype.slice.call(document.querySelectorAll(
'.gal-tile, .trainer-item .trainer-pic, .icon-box-item, .price-card')).map(function (el) { '.trainer-item .trainer-pic, .icon-box-item, .price-card')).map(function (el) {
return { el: el, strip: !!el.closest('.gal-marquee, .mod-scroll'), on: false }; return { el: el, strip: !!el.closest('.mod-scroll'), on: false };
}); });
if (!items.length) return; if (!items.length) return;
var last = 0; var last = 0;
+3 -2
View File
@@ -6,8 +6,9 @@
// 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.
$files = glob(rtrim($gallery_path, '/') . '/*.jpg'); // Every format the admin upload accepts (functions_galerias.php), not just .jpg.
if (!$files || !is_array($files)) $files = []; $files = preg_grep('/\.(jpe?g|png|gif|webp|jxl)$/i', glob(rtrim($gallery_path, '/') . '/*') ?: []);
$files = array_values($files ?: []);
// 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';
+1 -1
View File
@@ -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 !== '') ? glob($gallery_path . '*.jpg') : []; $gallery_files = ($on('galeria') && $gallery_path !== '') ? preg_grep('/\.(jpe?g|png|gif|webp|jxl)$/i', glob($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';
?> ?>
+2 -2
View File
@@ -82,8 +82,8 @@ include __DIR__ . '/../../../includes/header.php';
<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"> <input type="file" class="form-control" name="image" accept=".jpg,.jpeg,.png,.gif,.webp,.jxl,image/jxl">
<div class="form-text">JPG, PNG, GIF ou WebP.</div> <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> </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" class="btn btn-primary btn-sm"><i class="bi bi-cloud-arrow-up me-1"></i>Carregar</button>
</form> </form>