← back to Bubbesblock

public/app.js

260 lines

/* BubbesBlock interaction layer — makes every control real. Loaded after chrome.js. */
(function () {
  const BB = window.BB = { you: null };
  const api = {
    get: (u) => fetch(u, { credentials: 'same-origin' }).then(r => r.json()),
    post: (u, body) => fetch(u, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }).then(async r => { const j = await r.json().catch(() => ({})); if (!r.ok) throw Object.assign(new Error(j.error || r.status), { status: r.status, data: j }); return j; })
  };
  BB.api = api;
  const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  const safeUrl = u => (typeof u === 'string' && /^(\/|https:\/\/)/.test(u)) ? u : '#';
  BB.esc = esc;

  /* ---------- toast ---------- */
  function toast(msg) {
    let t = document.getElementById('bb-toast');
    if (!t) { t = document.createElement('div'); t.id = 'bb-toast'; document.body.appendChild(t); }
    t.textContent = msg; t.className = 'show';
    clearTimeout(t._t); t._t = setTimeout(() => t.className = '', 2600);
  }
  BB.toast = toast;

  /* ---------- modal ---------- */
  function modal(title, inner, opts = {}) {
    close();
    const back = document.createElement('div'); back.className = 'bb-modal-back'; back.id = 'bb-modal';
    back.innerHTML = `<div class="bb-modal" role="dialog" aria-modal="true">
      <div class="bb-modal-hd"><b>${title}</b><button class="bb-x" aria-label="Close">&times;</button></div>
      <div class="bb-modal-bd">${inner}</div></div>`;
    document.body.appendChild(back);
    back.querySelector('.bb-x').onclick = close;
    back.onclick = e => { if (e.target === back) close(); };
    document.addEventListener('keydown', escClose);
    if (opts.onOpen) opts.onOpen(back);
    const f = back.querySelector('input,textarea'); if (f) f.focus();
    return back;
  }
  function close() { const m = document.getElementById('bb-modal'); if (m) m.remove(); document.removeEventListener('keydown', escClose); }
  function escClose(e) { if (e.key === 'Escape') close(); }
  BB.modal = modal; BB.closeModal = close;

  /* ---------- session / auth gates ---------- */
  BB.refresh = async () => { const s = await api.get('/api/session'); BB.you = s.you; paintYou(); return BB.you; };
  function paintYou() {
    // reflect verified state on any composer placeholders, etc.
    document.querySelectorAll('[data-when-auth]').forEach(el => { el.style.display = BB.you ? '' : 'none'; });
    document.querySelectorAll('[data-when-anon]').forEach(el => { el.style.display = BB.you ? 'none' : ''; });
  }

  BB.ensureAuth = function () {
    return new Promise(resolve => {
      if (BB.you) return resolve(BB.you);
      modal('Sign in to your block', `
        <p class="bb-sub">Join your neighbors on Beekman Ave. Enter your name to continue.</p>
        <input id="bb-name" placeholder="Your name (e.g. Steve Abrams)" />
        <button class="bb-btn" id="bb-login">Continue</button>
        <p class="bb-fine">Posting also needs a claimed address — one tap after this.</p>`, {
        onOpen(m) {
          const go = async () => {
            const name = m.querySelector('#bb-name').value.trim(); if (!name) return;
            try { const r = await api.post('/api/session/login', { name }); BB.you = r.you; paintYou(); close(); toast('Welcome, ' + r.you.name.split(' ')[0] + '!'); resolve(r.you); }
            catch (e) { toast('Sign-in failed'); }
          };
          m.querySelector('#bb-login').onclick = go;
          m.querySelector('#bb-name').addEventListener('keydown', e => { if (e.key === 'Enter') go(); });
        }
      });
    });
  };

  BB.ensureVerified = async function () {
    const you = await BB.ensureAuth(); if (!you) return null;
    if (you.verified) return you;
    return new Promise(resolve => {
      modal('Claim your address', `
        <p class="bb-sub">Verify you live on the block to post & reply. (One-tap demo of the claimmyaddress.com flow.)</p>
        <input id="bb-addr" placeholder="Your address" value="${you.address || ''}" />
        <button class="bb-btn" id="bb-claim">Claim &amp; continue</button>
        <a class="bb-fine" href="https://claimmyaddress.com" target="_blank">Powered by claimmyaddress.com →</a>`, {
        onOpen(m) {
          m.querySelector('#bb-claim').onclick = async () => {
            const address = m.querySelector('#bb-addr').value.trim(); if (!address) return;
            try { const r = await api.post('/api/claim', { address }); BB.you = r.you; paintYou(); close(); toast('Address claimed ✓'); resolve(r.you); }
            catch (e) { toast('Claim failed'); }
          };
        }
      });
    });
  };

  /* ---------- composer ---------- */
  const CATS = [['📣 Block Post', 'plum'], ['🍯 Cup of Sugar', 'green'], ['🕯️ Shabbat Dinner Club', 'plum'], ['🌿 Block Events', 'green'], ['🐱 Lost & Found', 'amber'], ['🏚️ Who Lived Here', 'green']];
  BB.composer = async function () {
    const you = await BB.ensureVerified(); if (!you) return;
    modal('Share with the block', `
      <select id="bb-cat" class="bb-input">${CATS.map(c => `<option value="${c[0]}|${c[1]}">${c[0]}</option>`).join('')}</select>
      <textarea id="bb-body" rows="5" placeholder="What's happening on the block, ${you.name.split(' ')[0]}?"></textarea>
      <button class="bb-btn" id="bb-post">Post to the block</button>`, {
      onOpen(m) {
        m.querySelector('#bb-post').onclick = async () => {
          const body = m.querySelector('#bb-body').value.trim(); if (!body) return;
          const [category, catColor] = m.querySelector('#bb-cat').value.split('|');
          try { await api.post('/api/posts', { body, category, catColor }); close(); toast('Posted ✓'); if (location.pathname === '/') location.reload(); }
          catch (e) { toast(e.status === 403 ? 'Claim your address first' : 'Post failed'); }
        };
      }
    });
  };

  /* ---------- reactions / share / bookmark ---------- */
  BB.react = async function (btn) {
    const you = await BB.ensureAuth(); if (!you) return;
    const [targetType, targetId] = btn.dataset.react.split(':');
    try {
      const r = await api.post('/api/react', { targetType, targetId, kind: 'thank' });
      btn.classList.toggle('on', r.on);
      const c = btn.querySelector('.cnt'); if (c) c.textContent = r.count;
      else if (r.count) btn.insertAdjacentHTML('beforeend', ` <span class="cnt">${r.count}</span>`);
    } catch (e) { toast('Try again'); }
  };
  BB.share = async function (id) {
    try { const r = await api.post(`/api/posts/${id}/share`, {}); } catch {}
    const url = location.origin + '/p/' + id;
    modal('Share this post', `
      <p class="bb-sub">Copy the link or send it to a neighbor.</p>
      <input id="bb-url" class="bb-input" readonly value="${url}" />
      <button class="bb-btn" id="bb-copy">Copy link</button>`, {
      onOpen(m) { m.querySelector('#bb-copy').onclick = () => { m.querySelector('#bb-url').select(); navigator.clipboard?.writeText(url); toast('Link copied'); close(); }; }
    });
  };

  /* ---------- comments ---------- */
  BB.sendComment = async function (postId, input, isReply) {
    const you = await BB.ensureVerified(); if (!you) { return; }
    const body = input.value.trim(); if (!body) return;
    try {
      const r = await api.post(`/api/posts/${postId}/comments`, { body, reply: !!isReply });
      input.value = '';
      const list = document.getElementById('bb-comments');
      if (list && window.renderComment) list.insertAdjacentHTML('beforeend', window.renderComment(r.comment));
      toast('Reply posted ✓');
    } catch (e) { toast(e.status === 403 ? 'Claim your address first' : 'Reply failed'); }
  };

  /* ---------- opportunities ---------- */
  BB.respond = async function (id, neighbor) {
    const you = await BB.ensureVerified(); if (!you) return;
    modal('Respond to ' + (neighbor || 'this neighbor'), `
      <p class="bb-sub">Introduce yourself and your services. They'll see this in their inbox.</p>
      <textarea id="bb-msg" rows="4" placeholder="Hi! I'd love to help with this…"></textarea>
      <button class="bb-btn" id="bb-send">Send response</button>`, {
      onOpen(m) {
        m.querySelector('#bb-send').onclick = async () => {
          const body = m.querySelector('#bb-msg').value.trim(); if (!body) return;
          try { const r = await api.post(`/api/opportunities/${id}/respond`, { body }); close(); toast('Response sent ✓'); document.dispatchEvent(new CustomEvent('opp-responded', { detail: { id, responses: r.responses } })); }
          catch (e) { toast(e.status === 403 ? 'Claim your address first' : 'Failed'); }
        };
      }
    });
  };
  BB.unlock = async function (id, card) {
    const you = await BB.ensureAuth(); if (!you) return;
    try { await api.post(`/api/opportunities/${id}/unlock`, {}); document.dispatchEvent(new CustomEvent('opp-unlocked', { detail: { id } })); toast('Lead unlocked ✓'); }
    catch (e) { toast('Failed'); }
  };

  /* ---------- search ---------- */
  let searchT;
  BB.wireSearch = function (input) {
    if (!input) return;
    let dd = document.createElement('div'); dd.className = 'bb-search-dd'; input.parentNode.appendChild(dd);
    input.addEventListener('input', () => {
      clearTimeout(searchT); const term = input.value.trim();
      if (term.length < 2) { dd.classList.remove('show'); return; }
      searchT = setTimeout(async () => {
        const r = await api.get('/api/search?q=' + encodeURIComponent(term));
        const rows = [
          ...r.posts.map(p => `<a href="/p/${p.id}"><b>${p.category || 'Post'}</b> — ${(Array.isArray(p.body) ? p.body[0] : p.body || '').slice(0, 60)}</a>`),
          ...r.opportunities.map(o => `<a href="/opportunities"><b>${o.category}</b> — ${o.body.slice(0, 60)}</a>`),
          ...r.neighbors.map(n => `<a href="/search?q=${encodeURIComponent(n.name)}"><b>👤 ${n.name}</b> ${n.address || ''}</a>`)
        ];
        dd.innerHTML = rows.length ? rows.join('') : '<div class="bb-empty">No matches</div>';
        dd.classList.add('show');
      }, 220);
    });
    input.addEventListener('keydown', e => { if (e.key === 'Enter' && input.value.trim()) location.href = '/search?q=' + encodeURIComponent(input.value.trim()); });
    document.addEventListener('click', e => { if (!input.parentNode.contains(e.target)) dd.classList.remove('show'); });
  };

  /* ---------- notifications + inbox dropdowns ---------- */
  function dropdown(btn, html) {
    document.querySelectorAll('.bb-dd-pop').forEach(x => x.remove());
    const pop = document.createElement('div'); pop.className = 'bb-dd-pop'; pop.innerHTML = html;
    document.body.appendChild(pop);
    const r = btn.getBoundingClientRect();
    pop.style.top = (r.bottom + 6) + 'px'; pop.style.right = (window.innerWidth - r.right) + 'px';
    setTimeout(() => document.addEventListener('click', function h(e) { if (!pop.contains(e.target) && e.target !== btn) { pop.remove(); document.removeEventListener('click', h); } }), 0);
    return pop;
  }
  BB.openNotifications = async function (btn) {
    const you = await BB.ensureAuth(); if (!you) return;
    const r = await api.get('/api/notifications');
    const items = r.items.length ? r.items.map(n => `<a href="${safeUrl(n.link)}" class="bb-noti ${n.read ? '' : 'unread'}"><b>${esc(n.actor)}</b> ${esc(n.text)}</a>`).join('') : '<div class="bb-empty">No notifications yet</div>';
    dropdown(btn, `<div class="bb-dd-hd">Notifications<button id="bb-mark">Mark all read</button></div>${items}`);
    const mk = document.getElementById('bb-mark'); if (mk) mk.onclick = async () => { await api.post('/api/notifications/read', {}); document.querySelectorAll('.bb-noti').forEach(x => x.classList.remove('unread')); BB.updateBadges(); };
  };
  BB.openInbox = async function (btn) {
    const you = await BB.ensureAuth(); if (!you) return;
    const r = await api.get('/api/inbox');
    const items = r.conversations.length ? r.conversations.map(c => `<a href="/inbox" class="bb-noti"><b>${c.peer}</b> <span>${(c.last || '').slice(0, 48)}</span></a>`).join('') : '<div class="bb-empty">Your inbox is empty</div>';
    dropdown(btn, `<div class="bb-dd-hd">Messages</div>${items}`);
  };

  BB.updateBadges = async function () {
    if (!BB.you) return;
    const n = await api.get('/api/notifications');
    document.querySelectorAll('[data-badge="notifications"]').forEach(b => { b.textContent = n.unread || ''; b.style.display = n.unread ? '' : 'none'; });
  };

  /* ---------- avatar menu ---------- */
  BB.avatarMenu = async function (btn) {
    const you = BB.you;
    const html = you
      ? `<div class="bb-dd-hd">${you.name}</div><a href="/bookmarks" class="bb-noti">🔖 Bookmarks</a><a href="#" class="bb-noti" id="bb-logout">↪ Log out</a>`
      : `<div class="bb-dd-hd">Welcome</div><a href="#" class="bb-noti" id="bb-signin">Sign in</a>`;
    dropdown(btn, html);
    const lo = document.getElementById('bb-logout'); if (lo) lo.onclick = async e => { e.preventDefault(); await api.post('/api/session/logout', {}); location.reload(); };
    const si = document.getElementById('bb-signin'); if (si) si.onclick = e => { e.preventDefault(); BB.ensureAuth(); };
  };

  /* ---------- global delegation ---------- */
  document.addEventListener('click', e => {
    const t = e.target.closest('[data-react],[data-share],[data-reply],[data-respond],[data-unlock],[data-composer],[data-panel],[data-avatar],[data-more]');
    if (!t) return;
    if (t.dataset.react !== undefined) { e.preventDefault(); BB.react(t); }
    else if (t.dataset.share !== undefined) { e.preventDefault(); BB.share(t.dataset.share); }
    else if (t.dataset.reply !== undefined) { e.preventDefault(); const ci = document.getElementById('bb-comment-input'); if (ci) ci.focus(); }
    else if (t.dataset.respond !== undefined) { e.preventDefault(); BB.respond(t.dataset.respond, t.dataset.neighbor); }
    else if (t.dataset.unlock !== undefined) { e.preventDefault(); BB.unlock(t.dataset.unlock, t.closest('.opp')); }
    else if (t.dataset.composer !== undefined) { e.preventDefault(); BB.composer(); }
    else if (t.dataset.panel === 'notifications') { e.preventDefault(); BB.openNotifications(t); }
    else if (t.dataset.panel === 'inbox') { e.preventDefault(); BB.openInbox(t); }
    else if (t.dataset.avatar !== undefined) { e.preventDefault(); BB.avatarMenu(t); }
    else if (t.dataset.more !== undefined) { e.preventDefault(); BB.moreMenu(t); }
  });

  BB.moreMenu = function (btn) {
    const id = (btn.dataset.more || '').split(':')[1];
    dropdown(btn, `<a href="#" class="bb-noti" data-bm="${id}">🔖 Save post</a><a href="#" class="bb-noti" data-share="${id}">↗ Share</a><a href="/p/${id}" class="bb-noti">🔗 Open post</a>`);
    const bm = document.querySelector(`[data-bm="${id}"]`); if (bm) bm.onclick = async e => { e.preventDefault(); const you = await BB.ensureAuth(); if (!you) return; try { const r = await BB.api.post(`/api/posts/${id}/bookmark`, {}); toast(r.on ? 'Saved 🔖' : 'Removed'); } catch { toast('Try again'); } };
  };

  /* ---------- init ---------- */
  BB.init = async function () {
    await BB.refresh();
    BB.wireSearch(document.querySelector('.search input'));
    BB.updateBadges();
  };
  document.addEventListener('DOMContentLoaded', () => { if (window.__bbAutoInit !== false) BB.init(); });
})();