PageSourceSearch

https://app.replicants.ai/js/meetup-visual.js?v=20261006gm1

js replicants.ai collected 2026-10-05 12:31:01 UTC 10,686 bytes, 182 lines download raw bytes

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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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.