← 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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">×</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 & 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(); });
})();