1/* meetup-visual.js â a meetup's map and photos on its card (window.MeetupVisual). 2 * 3 * Migration 949. Alex, 4 Oct 2026, on Claire's "Coffee and a walk": "It'd be good 4 * if the map was actually shown on that bit where it says 'Meet in person.' It 5 * could even have images of the venue⦠images of previous events there as well 6 * so it just sells the idea of a physical thing a bit better. Instead of saying 7 * 'Open the map,' the map could be showing." 8 * 9 * ONE FILE, TWO SURFACES: the member site inlines it before the Spaces script 10 * (shell-renderer _spacesMemberScript, like calendar-invite.js), and the coach 11 * app loads it before space-live-room.js. Both draw the same card parts: 12 * 13 * mapHtml(e) the map, when the server sent one (e.map_embed_url: Google's 14 * Maps Embed API in PLACE mode, meetups-service embedUrl), with 15 * an "Open the map" pill to the full Google Maps page. No embed 16 * (no key on this server): '' and the caller keeps its "Open 17 * the map" link. 18 * photosHtml(e) the coach's venue and past-meetup photos (e.photos), a row 19 * that scrolls sideways; a tap opens the picture large. 20 * 21 * THE MAP IS GOOGLE'S, IN PLACE MODE ONLY: free with unlimited use, and Google 22 * takes the address itself, so nothing is geocoded and no tiles are ours to 23 * fetch. OpenStreetMap's public tile server blocked these cards on dev within a 24 * day ("not following the tile usage policy"), which is why this is not OSM. 25 * Both CSPs allow the frame by the narrow prefix https://www.google.com/maps/embed/v1/. 26 * 27 * â ï¸ PRIVACY: NOTHING IS LOADED FROM GOOGLE UNTIL THE MAP IS ON SCREEN. A card 28 * drawn off screen holds only the address in a data attribute; an 29 * IntersectionObserver gives the iframe its src the first time the map is 30 * actually visible. Cards are drawn with innerHTML in many places, so one 31 * MutationObserver finds new maps wherever they appear and no caller has to 32 * remember to switch them on. 33 */ 34(function () { 35 'use strict'; 36 if (typeof window === 'undefined' || window.MeetupVisual) return; 37 38 var EMBED_RE = /^https:\/\/www\.google\.com\/maps\/embed\/v1\/place\?/; 39 40 function _e(v) { 41 return String(v == null ? '' : v).replace(/[&<>"']/g, function (c) { 42 return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; 43 }); 44 } 45 /* A photo url as the server sends it: the community image store's own path, 46 or the CDN copy the demo sync makes. Anything else is not drawn. */ 47 function _okUrl(u) { return typeof u === 'string' && (/^\/uploads\/outreach-media\//.test(u) || /^https:\/\//.test(u)); } 48 49 function mapHtml(e, opts) { 50 var src = e && e.map_embed_url; 51 if (!src || !EMBED_RE.test(src) || !e.map_url || !/^https:\/\//.test(e.map_url)) return ''; 52 var o = opts || {}; 53 var where = e.venue_name || e.address || 'the meetup'; 54 return '<div class="mv-map' + (o.flush ? ' mv-flush' : '') + '" data-mv-map data-src="' + _e(src) + '"' + 55 (o.height ? ' style="height:' + Number(o.height) + 'px"' : '') + '>' + 56 '<iframe title="Map of ' + _e(where) + '" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>' + 57 '<a class="mv-open" href="' + _e(e.map_url) + '" target="_blank" rel="noopener noreferrer" aria-label="Open the map for ' + _e(where) + ' in Google Maps">Open the map</a>' + 58 '</div>'; 59 } 60 61 var KIND_WORDS = { venue: 'The venue', past: 'A past meetup' }; 62 function photosHtml(e) { 63 var list = (e && Array.isArray(e.photos) ? e.photos : []).filter(function (p) { return p && _okUrl(p.url); }); 64 if (!list.length) return ''; 65 return '<div class="mv-photos" role="list" aria-label="Photos">' + list.map(function (p, i) { 66 var tag = KIND_WORDS[p.kind] || KIND_WORDS.venue; 67 return '<button type="button" class="mv-ph" role="listitem" data-mv-photo="' + i + '" aria-label="' + _e(tag) + ', photo ' + (i + 1) + ' of ' + list.length + '">' + 68 '<img src="' + _e(p.url) + '" alt="" loading="lazy" decoding="async" onerror="this.closest(\'.mv-ph\').remove()">' + 69 '<span class="mv-tag">' + _e(tag) + '</span></button>'; 70 }).join('') + '</div>'; 71 } 72 73 /* ââ The map, given its src once it is on screen âââââââââââââââââââââ */ 74 function _lay(el) { 75 if (el.getAttribute('data-mv-on')) return; 76 el.setAttribute('data-mv-on', '1'); 77 var src = el.getAttribute('data-src') || ''; 78 var f = el.querySelector('iframe'); 79 if (f && EMBED_RE.test(src)) f.src = src; 80 } 81 82 var _io = null; 83 function _watch(el) { 84 if (el.getAttribute('data-mv-seen')) return; 85 el.setAttribute('data-mv-seen', '1'); 86 if (!_io) { _lay(el); return; } 87 _io.observe(el); 88 } 89 function scan(root) { 90 var r = root && root.querySelectorAll ? root : document; 91 var els = r.querySelectorAll('[data-mv-map]:not([data-mv-seen])'); 92 for (var i = 0; i < els.length; i++) _watch(els[i]); 93 } 94 if (typeof IntersectionObserver === 'function') { 95 _io = new IntersectionObserver(function (entries) {
96 entries.forEach(function (en) { 97 if (!en.isIntersecting) return; 98 _io.unobserve(en.target); 99 _lay(en.target); 100 }); 101 }, { rootMargin: '0px', threshold: 0.01 }); 102 } 103 var _queued = false; 104 function _queue() { 105 if (_queued) return; _queued = true; 106 setTimeout(function () { _queued = false; scan(document); }, 30); 107 } 108 if (typeof MutationObserver === 'function') { 109 var start = function () { new MutationObserver(_queue).observe(document.body || document.documentElement, { childList: true, subtree: true }); _queue(); }; 110 if (document.body) start(); else document.addEventListener('DOMContentLoaded', start); 111 } 112 113 /* ââ A photo, large (our own overlay, never a new tab or a native dialog) ââ */ 114 function _open(btn) { 115 var row = btn.closest('.mv-photos'); if (!row) return; 116 var items = Array.prototype.slice.call(row.querySelectorAll('.mv-ph')); 117 var at = items.indexOf(btn); if (at < 0) return; 118 var back = document.activeElement; 119 var ov = document.createElement('div'); 120 ov.className = 'mv-lb'; ov.setAttribute('role', 'dialog'); ov.setAttribute('aria-modal', 'true'); ov.setAttribute('aria-label', 'Photo'); 121 ov.innerHTML = '<button type="button" class="mv-lb-x" aria-label="Close">Ã</button>' + 122 '<figure><img alt=""><figcaption></figcaption></figure>' + 123 (items.length > 1 ? '<button type="button" class="mv-lb-nav mv-lb-prev" aria-label="Previous photo">â¹</button><button type="button" class="mv-lb-nav mv-lb-next" aria-label="Next photo">âº</button>' : ''); 124 function show(i) { 125 at = (i + items.length) % items.length; 126 var it = items[at]; 127 ov.querySelector('img').src = it.querySelector('img').src; 128 ov.querySelector('figcaption').textContent = (it.querySelector('.mv-tag') || {}).textContent || ''; 129 } 130 function close() { document.removeEventListener('keydown', key); ov.remove(); if (back && back.focus) back.focus(); } 131 function key(ev) { 132 if (ev.key === 'Escape') close(); 133 else if (ev.key === 'ArrowRight' && items.length > 1) show(at + 1); 134 else if (ev.key === 'ArrowLeft' && items.length > 1) show(at - 1); 135 } 136 ov.addEventListener('click', function (ev) { 137 if (ev.target.closest('.mv-lb-next')) return show(at + 1); 138 if (ev.target.closest('.mv-lb-prev')) return show(at - 1); 139 if (ev.target.closest('.mv-lb-x') || ev.target === ov) close(); 140 }); 141 document.addEventListener('keydown', key); 142 document.body.appendChild(ov); 143 show(at); 144 ov.querySelector('.mv-lb-x').focus(); 145 } 146 document.addEventListener('click', function (ev) { 147 var b = ev.target && ev.target.closest ? ev.target.closest('[data-mv-photo]') : null; 148 if (!b) return; 149 ev.preventDefault(); 150 _open(b); 151 }); 152 153 /* The styles: the member site's tokens (--oc-*) first, the coach app's after, 154 so the one file sits right on both. */ 155 (function css() { 156 if (document.getElementById('mv-css')) return; 157 var B = 'var(--oc-border,var(--border,#e5e7eb))'; 158 var S2 = 'var(--oc-surface-2,var(--bg-subtle,#f3f4f6))'; 159 var ACC = 'var(--oc-accent-solid,var(--accent,#6c63ff))'; 160 var st = document.createElement('style'); st.id = 'mv-css'; 161 st.textContent = 162 '.mv-map{position:relative;height:220px;border-radius:12px;overflow:hidden;background:' + S2 + ';border:1px solid ' + B + ';isolation:isolate}' + 163 '.mv-map.mv-flush{border-radius:14px 14px 0 0;border:0;border-bottom:1px solid ' + B + ';margin:-16px -16px 4px}' + 164 '.mv-map iframe{position:absolute;inset:0;width:100%;height:100%;border:0;display:block}' + 165 '.mv-open{position:absolute;z-index:1;right:10px;top:10px;text-decoration:none;background:rgba(255,255,255,.95);color:#111827;font-size:14px;font-weight:600;padding:7px 12px;border-radius:999px;box-shadow:0 1px 4px rgba(0,0,0,.2)}' + 166 '.mv-photos{display:flex;gap:8px;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;padding:2px 0 4px;scrollbar-width:thin}' + 167 '.mv-ph{position:relative;flex:0 0 auto;width:168px;height:118px;padding:0;border:0;border-radius:10px;overflow:hidden;cursor:zoom-in;scroll-snap-align:start;background:' + S2 + '}' + 168 '.mv-ph:focus-visible{outline:3px solid ' + ACC + ';outline-offset:2px}' + 169 '.mv-ph img{width:100%;height:100%;object-fit:cover;display:block}' + 170 '.mv-tag{position:absolute;
170left:6px;bottom:6px;background:rgba(17,24,39,.72);color:#fff;font-size:12px;font-weight:600;padding:3px 8px;border-radius:999px}' + 171 '@media(min-width:700px){.mv-map{height:260px}.mv-ph{width:196px;height:136px}}' + 172 '.mv-lb{position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,.88);display:flex;align-items:center;justify-content:center;padding:56px 12px}' + 173 '.mv-lb figure{margin:0;max-width:min(1100px,100%);max-height:100%;display:flex;flex-direction:column;align-items:center;gap:10px}' + 174 '.mv-lb img{max-width:100%;max-height:calc(100vh - 150px);border-radius:10px;object-fit:contain}' + 175 '.mv-lb figcaption{color:#fff;font-size:15px}' + 176 '.mv-lb-x,.mv-lb-nav{position:absolute;background:rgba(255,255,255,.14);color:#fff;border:0;border-radius:999px;width:48px;height:48px;font-size:30px;line-height:1;cursor:pointer}' + 177 '.mv-lb-x{top:10px;right:10px}.mv-lb-prev{left:10px;top:50%;margin-top:-24px}.mv-lb-next{right:10px;top:50%;margin-top:-24px}'; 178 (document.head || document.documentElement).appendChild(st); 179 })(); 180 181 window.MeetupVisual = { mapHtml: mapHtml, photosHtml: photosHtml, scan: scan }; 182})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.