From d70f13aea0c05a8f2f1b77d24672e7e832832214 Mon Sep 17 00:00:00 2001 From: andre Date: Fri, 25 Sep 2026 07:05:25 +0100 Subject: [PATCH] feat(galeria): masonry em JS com lightbox, em vez do slider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - «Conhece as nossas instalações» (e a galeria do CrossFit) passa de slider Splide a masonry: layout próprio em JS (sem biblioteca), fotos com a proporção original, coluna mais baixa primeiro e algumas a ocupar duas colunas (só quando não deixam buraco). Reorganiza ao mudar de largura (ResizeObserver + resize) e as fotos entram em cascata ao aparecer. Sem JS fica uma grelha CSS. - Clicar abre a foto num : anterior/seguinte, setas, Esc, deslizar. - O Splide deixa de ser carregado (CSS e JS). Co-Authored-By: Claude Opus 5.5 --- site/assets/css/style.css | 201 ++++++++++++++++++++++++++++++++++++++ site/assets/js/main.js | 141 ++++++++++++++++++++++++++ site/includes/footer.php | 1 - site/includes/gallery.php | 69 +++++++------ site/includes/include.php | 3 - 5 files changed, 379 insertions(+), 36 deletions(-) diff --git a/site/assets/css/style.css b/site/assets/css/style.css index 48228ec..3ca05db 100644 --- a/site/assets/css/style.css +++ b/site/assets/css/style.css @@ -4095,3 +4095,204 @@ h3.contact-title { @media (prefers-reduced-motion: reduce) { .mi, .mi [pathLength] { animation: none !important; } } + +/* ══════════════════════════════════════════════════════════════════════════ + Galeria — masonry + lightbox (site/includes/gallery.php) + Without JS: a plain CSS grid (photos cropped to equal tiles). With JS + (.is-masonry): main.js positions every tile absolutely — shortest column + first, true aspect ratio, some tiles two columns wide — and sets the list's + height. 4 columns on desktop, 3 on tablets, 2 on phones. + ══════════════════════════════════════════════════════════════════════════ */ +.gal-masonry { + list-style: none; + margin: 0 0 70px; + padding: 0; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-auto-rows: 170px; + grid-auto-flow: dense; + gap: 12px; +} + +.gal-tile { position: relative; margin: 0; } +.gal-w { grid-column: span 2; } + +/* JS masonry: absolute tiles, sized and placed by main.js. */ +.gal-masonry.is-masonry { + display: block; + position: relative; +} + +.gal-masonry.is-masonry .gal-tile { + position: absolute; + top: 0; + left: 0; + transition: transform .5s cubic-bezier(.2, .8, .2, 1), width .5s cubic-bezier(.2, .8, .2, 1), height .5s cubic-bezier(.2, .8, .2, 1), opacity .6s ease; +} + +/* Entrance: tiles rise in one after another when the gallery comes into view + (main.js adds .is-in with a per-tile delay). */ +.gal-masonry.is-masonry .gal-tile:not(.is-in) { + opacity: 0; +} + +.gal-masonry.is-masonry .gal-tile .gal-link { + transition: transform .6s cubic-bezier(.2, .8, .2, 1); +} + +.gal-masonry.is-masonry .gal-tile:not(.is-in) .gal-link { + transform: translateY(24px) scale(.97); +} + +.gal-link { + display: block; + width: 100%; + height: 100%; + overflow: hidden; + border-radius: 6px; + position: relative; + background: var(--fitme-surface-2); + cursor: zoom-in; +} + +.gal-link img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + transition: transform .6s cubic-bezier(.2, .8, .2, 1), filter .4s ease; +} + +/* Accent frame + slow zoom on hover / focus. */ +.gal-link::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset 0 0 0 0 var(--fitme-accent); + transition: box-shadow .3s ease; + pointer-events: none; +} + +.gal-link:hover img, +.gal-link:focus-visible img { + transform: scale(1.06); + filter: brightness(1.05); +} + +.gal-link:hover::after, +.gal-link:focus-visible::after { + box-shadow: inset 0 0 0 3px var(--fitme-accent); +} + +@media (max-width: 991px) { + .gal-masonry { grid-template-columns: repeat(3, minmax(0, 1fr)); grid-auto-rows: 150px; } +} + +@media (max-width: 575px) { + .gal-masonry { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 120px; gap: 8px; margin-bottom: 48px; } +} + +/* Lightbox */ +.gal-lightbox { + width: 100vw; + height: 100vh; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: #fff; +} + +.gal-lightbox[open] { + display: flex; + align-items: center; + justify-content: center; +} + +.gal-lightbox::backdrop { + background: rgba(11, 11, 13, .92); +} + +.gal-lb-figure { + margin: 0; + max-width: calc(100vw - 140px); + max-height: calc(100vh - 80px); + display: flex; + flex-direction: column; + align-items: center; +} + +.gal-lb-figure img { + max-width: 100%; + max-height: calc(100vh - 110px); + object-fit: contain; + border-radius: 4px; + box-shadow: 0 20px 60px rgba(0, 0, 0, .5); + animation: gal-in .35s ease; +} + +@keyframes gal-in { + from { opacity: 0; transform: scale(.97); } + to { opacity: 1; transform: none; } +} + +.gal-lb-count { + margin-top: 10px; + font-size: .85rem; + color: var(--fitme-ash); + letter-spacing: .08em; +} + +.gal-lb-close, +.gal-lb-nav { + position: absolute; + border: 0; + background: rgba(255, 255, 255, .08); + color: #fff; + cursor: pointer; + transition: background var(--fitme-transition), color var(--fitme-transition); +} + +.gal-lb-close:hover, +.gal-lb-nav:hover { + background: var(--fitme-accent); + color: var(--fitme-dark); +} + +.gal-lb-close { + top: 16px; + right: 16px; + width: 44px; + height: 44px; + border-radius: 50%; + font-size: 1.8rem; + line-height: 1; +} + +.gal-lb-nav { + top: 50%; + transform: translateY(-50%); + width: 48px; + height: 48px; + border-radius: 50%; + font-size: 1.1rem; +} + +.gal-lb-prev { left: 20px; } +.gal-lb-next { right: 20px; } + +@media (max-width: 575px) { + .gal-lb-figure { max-width: calc(100vw - 24px); } + .gal-lb-nav { top: auto; bottom: 18px; transform: none; } + .gal-lb-prev { left: calc(50% - 60px); } + .gal-lb-next { right: calc(50% - 60px); } +} + +@media (prefers-reduced-motion: reduce) { + .gal-link img, .gal-lb-figure img { transition: none; animation: none; } + .gal-masonry.is-masonry .gal-tile, + .gal-masonry.is-masonry .gal-tile .gal-link { transition: none; } +} diff --git a/site/assets/js/main.js b/site/assets/js/main.js index 3798111..912ab72 100644 --- a/site/assets/js/main.js +++ b/site/assets/js/main.js @@ -695,3 +695,144 @@ document.addEventListener('DOMContentLoaded', function () { btn.addEventListener('click', function () { select.value = btn.getAttribute('data-membership'); }); }); }); + +/* ── Galeria: lightbox ─────────────────────────────────────────────────────── + Tiles are plain links to the full image (works without JS). With JS a click + opens the instead: prev / next buttons, ← → keys, Esc, swipe on + touch, and a click on the dark backdrop closes it. */ +document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('.gal-masonry').forEach(function (grid) { + var box = grid.parentElement.nextElementSibling; + if (!box || !box.classList.contains('gal-lightbox') || typeof box.showModal !== 'function') return; + var links = Array.prototype.slice.call(grid.querySelectorAll('.gal-link')); + var img = box.querySelector('.gal-lb-figure img'); + var count = box.querySelector('.gal-lb-count'); + var current = 0; + + function show(i) { + current = (i + links.length) % links.length; + var src = links[current].querySelector('img'); + img.src = links[current].getAttribute('href'); + img.alt = src ? src.alt : ''; + // Restart the fade-in for each photo. + img.style.animation = 'none'; void img.offsetWidth; img.style.animation = ''; + count.textContent = (current + 1) + ' / ' + links.length; + } + + links.forEach(function (a, i) { + a.addEventListener('click', function (e) { + e.preventDefault(); + show(i); + box.showModal(); + }); + }); + box.querySelector('.gal-lb-prev').addEventListener('click', function () { show(current - 1); }); + box.querySelector('.gal-lb-next').addEventListener('click', function () { show(current + 1); }); + box.querySelector('.gal-lb-close').addEventListener('click', function () { box.close(); }); + // Clicks that land on the dialog itself (not the photo or a button) are the backdrop. + box.addEventListener('click', function (e) { if (e.target === box) box.close(); }); + box.addEventListener('keydown', function (e) { + if (e.key === 'ArrowLeft') show(current - 1); + else if (e.key === 'ArrowRight') show(current + 1); + }); + box.addEventListener('close', function () { links[current].focus(); }); + + var x0 = null; + box.addEventListener('touchstart', function (e) { x0 = e.touches[0].clientX; }, { passive: true }); + box.addEventListener('touchend', function (e) { + if (x0 === null) return; + var dx = e.changedTouches[0].clientX - x0; + if (Math.abs(dx) > 40) show(current + (dx < 0 ? 1 : -1)); + x0 = null; + }, { passive: true }); + }); +}); + +/* ── Galeria: masonry layout ───────────────────────────────────────────────── + A small hand-rolled masonry (no library): the grid is split into columns + (4 / 3 / 2 by width), and each tile — at its photo's true aspect ratio, from + the width/height attributes the PHP writes — is dropped into the shortest + column; tiles marked .gal-w take two adjacent columns (the pair whose taller + one is lowest), which is what staggers the rows. Tiles are absolutely + positioned with transforms, so a relayout on resize glides into place. + The first time the gallery scrolls into view the tiles rise in one by one. */ +document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('.gal-masonry').forEach(function (grid) { + var tiles = Array.prototype.slice.call(grid.querySelectorAll('.gal-tile')); + if (!tiles.length) return; + grid.classList.add('is-masonry'); + + function ratio(tile) { + var img = tile.querySelector('img'); + var w = +img.getAttribute('width') || img.naturalWidth || 4; + var h = +img.getAttribute('height') || img.naturalHeight || 3; + return h / w; + } + function layout() { + var width = grid.clientWidth; + var cols = width >= 900 ? 4 : width >= 520 ? 3 : 2; + var gap = width >= 520 ? 12 : 8; + var colW = (width - gap * (cols - 1)) / cols; + var heights = []; + for (var c = 0; c < cols; c++) heights.push(0); + + tiles.forEach(function (tile) { + var span = (tile.classList.contains('gal-w') && cols > 1) ? 2 : 1; + // Best column (or pair of columns) = lowest top edge. + var pick = function (span) { + var best = 0, bestTop = Infinity, waste = 0; + for (var i = 0; i <= cols - span; i++) { + var cells = heights.slice(i, i + span); + var top = Math.max.apply(null, cells); + if (top < bestTop - 0.5) { bestTop = top; best = i; waste = top - Math.min.apply(null, cells); } + } + return [best, bestTop, waste]; + }; + var p = pick(span); + // A wide tile whose two columns end at different heights would + // leave a hole under the shorter one — use a single column then. + if (span === 2 && p[2] > 60) { span = 1; p = pick(1); } + var best = p[0], bestTop = p[1]; + var w = colW * span + gap * (span - 1); + var h = Math.round(w * ratio(tile)); + tile.style.width = w + 'px'; + tile.style.height = h + 'px'; + tile.style.transform = 'translate(' + (best * (colW + gap)) + 'px,' + bestTop + 'px)'; + for (var j = best; j < best + span; j++) heights[j] = bestTop + h + gap; + }); + grid.style.height = Math.max(0, Math.max.apply(null, heights) - gap) + 'px'; + } + + layout(); + // Re-lay out whenever the grid's own width changes (window resize, but + // also a gallery that was laid out while hidden / zero-width). + var t, lastW = grid.clientWidth; + var relayout = function () { + if (grid.clientWidth === lastW) return; + lastW = grid.clientWidth; + clearTimeout(t); t = setTimeout(layout, 80); + }; + if ('ResizeObserver' in window) new ResizeObserver(relayout).observe(grid); + window.addEventListener('resize', relayout, { passive: true }); // belt and braces; relayout() is idempotent + // Re-run once images report their size, in case an attribute was missing. + tiles.forEach(function (tile) { + var img = tile.querySelector('img'); + if (!img.getAttribute('width')) img.addEventListener('load', layout, { once: true }); + }); + + // Staggered entrance. + var enter = function () { + tiles.forEach(function (tile, i) { + setTimeout(function () { tile.classList.add('is-in'); }, Math.min(i, 12) * 70); + }); + }; + if ('IntersectionObserver' in window) { + var io = new IntersectionObserver(function (entries) { + if (entries.some(function (e) { return e.isIntersecting; })) { enter(); io.disconnect(); } + }, { rootMargin: '0px 0px -80px 0px' }); + io.observe(grid); + } else { + enter(); + } + }); +}); diff --git a/site/includes/footer.php b/site/includes/footer.php index f129bf4..ff35365 100644 --- a/site/includes/footer.php +++ b/site/includes/footer.php @@ -36,7 +36,6 @@ - diff --git a/site/includes/gallery.php b/site/includes/gallery.php index 4602bf2..4b9972c 100644 --- a/site/includes/gallery.php +++ b/site/includes/gallery.php @@ -1,9 +1,16 @@ +// lightbox with prev / next, arrow keys, Esc and swipe (main.js). $files = glob(rtrim($gallery_path, '/') . '/*.jpg'); if (!$files || !is_array($files)) $files = []; +// Column span per tile, cycled: w = two columns, s = one. +$gal_shapes = ['w', 's', 's', 's', 's', 'w', 's', 's', 's', 's', 's']; +$gal_label = $gallery_h2 ?? 'Instalações'; ?>
@@ -14,33 +21,31 @@ if (!$files || !is_array($files)) $files = [];
-
-
-
    - $f): /* distinct alt per photo — identical alts read as one image */ ?> -
  • <?= htmlspecialchars(($gallery_h2 ?? 'Instalações') . ' — FitMe 3070, foto ' . ($gi + 1)) ?> 0 ? ' loading="lazy"' : '' ?>>
  • - -
-
+ +
+
- + + + +
+ +
+
+ +
+ diff --git a/site/includes/include.php b/site/includes/include.php index b72999b..2cdb4be 100644 --- a/site/includes/include.php +++ b/site/includes/include.php @@ -46,9 +46,6 @@ $canonical = $GLOBALS['_canonical_override'] ?? base_url($page !== '' ? 'home/' - - -