// hyprcrd relay UI let me = null; let allTenants = []; function $(id) { return document.getElementById(id); } /** Show a toast in the bottom-right stack. kind = 'info' | 'ok' | 'err'. * Multiple toasts stack vertically, oldest at the top, and each one * slides in from the right + fades out after a fixed timeout. The * legacy `toast(msg, true)` second-arg style still works (true → err). */ /** Custom confirm dialog — returns a Promise. Replaces native * confirm() across the relay UI so all confirmations are styled. */ function relayConfirm(msg, { okLabel = 'Confirm', danger = true } = {}) { return new Promise(resolve => { const el = $('relay-confirm'); if (!el) { resolve(confirm(msg)); return; } // fallback $('relay-confirm-msg').textContent = msg; const yesBtn = $('relay-confirm-yes'); yesBtn.textContent = okLabel; yesBtn.className = danger ? 'btn btn-danger' : 'btn btn-primary'; el.classList.remove('hidden'); const cleanup = (result) => { el.classList.add('hidden'); yesBtn.replaceWith(yesBtn.cloneNode(true)); // strip listeners $('relay-confirm-no').replaceWith($('relay-confirm-no').cloneNode(true)); resolve(result); }; $('relay-confirm-yes').addEventListener('click', () => cleanup(true), { once: true }); $('relay-confirm-no').addEventListener('click', () => cleanup(false), { once: true }); }); } function toast(msg, kind = 'info') { if (kind === true) kind = 'err'; if (kind === false) kind = 'info'; const stack = $('toast-stack'); if (!stack) return; const el = document.createElement('div'); el.className = 'toast ' + kind; const icon = kind === 'err' ? 'ph-warning-circle' : kind === 'ok' ? 'ph-check-circle' : 'ph-info'; el.innerHTML = ``; el.querySelector('.toast-msg').textContent = msg; stack.appendChild(el); // Trigger the slide-in next frame so the transition runs requestAnimationFrame(() => el.classList.add('show')); setTimeout(() => { el.classList.add('fade-out'); setTimeout(() => el.remove(), 350); }, 2800); } async function api(path, opts = {}) { const res = await fetch(path, { method: opts.method || 'GET', headers: opts.body ? { 'Content-Type': 'application/json' } : {}, body: opts.body ? JSON.stringify(opts.body) : undefined, credentials: 'same-origin', }); return res.json().catch(() => ({ error: 'invalid response' })); } function showView(name) { // If the user hasn't changed their password yet, force them to that view // unless they're currently in setup/login/change-password (admin/anon flows) if (me && me.password_changed === false && !['setup', 'login', 'change-password'].includes(name)) { name = 'change-password'; } ['setup', 'login', 'change-password', 'compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].forEach(v => { $('view-' + v)?.classList.add('hidden'); }); $('view-' + name)?.classList.remove('hidden'); if (['compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].includes(name)) { $('nav-buttons').classList.remove('hidden'); $('nav-compose').classList.toggle('active', name === 'compose'); $('nav-inbox')?.classList.toggle('active', name === 'inbox'); $('nav-chat')?.classList.toggle('active', name === 'chat'); $('nav-shopping')?.classList.toggle('active', name === 'shopping'); $('nav-account').classList.toggle('active', name === 'account'); $('nav-admin').classList.toggle('active', name === 'admin'); $('nav-burger')?.classList.remove('hidden'); // Picking a view also collapses the mobile menu so it doesn't // hover over the freshly-shown view. $('nav-buttons').classList.remove('menu-open'); } else { // Setup, login, and forced change-password hide all nav $('nav-buttons').classList.add('hidden'); $('nav-burger')?.classList.add('hidden'); } if (name === 'compose') { loadTenants().then(() => populateRecipientPicker()); $('compose-body')?.focus(); } if (name === 'inbox') { loadInbox(); loadOutbox(); loadCollabInbox(); startInboxPoll(); } else { stopInboxPoll(); // Keep the header badge count fresh even outside the inbox // view — a quiet load updates the count without rendering the // full list (the DOM elements are in the hidden inbox view). loadInbox({ quiet: true }); } if (name === 'chat') { loadChatThreads(); startChatPoll(); } else { stopChatPoll(); } if (name === 'shopping') { loadShopping(); startShoppingPoll(); } else { stopShoppingPoll(); } // Remember the active view so a page reload lands back here // instead of defaulting to compose every time. if (['compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].includes(name)) { try { localStorage.setItem('relay-last-view', name); } catch {} } if (name === 'admin') { loadUsers(); } else if (name === 'account') { loadAccountSecret(); } else if (name === 'change-password') { $('cp-current')?.focus(); $('cp-err').textContent = ''; } } // ── Inbox auto-poll ── // // Refresh the inbox every 15s while the compose view is showing so new // messages from other tenants surface without a manual reload. Stops // the moment the user navigates away (account/admin/login) so we don't // hammer the server in the background. let inboxPollHandle = null; let inboxBusy = false; function startInboxPoll() { stopInboxPoll(); inboxPollHandle = setInterval(() => { if (inboxBusy) return; if (!$('view-inbox')?.classList.contains('hidden')) { loadInbox({ quiet: true }); loadOutbox({ quiet: true }); loadCollabInbox({ quiet: true }); } }, 15000); } function stopInboxPoll() { if (inboxPollHandle) { clearInterval(inboxPollHandle); inboxPollHandle = null; } } // Suppress poll re-renders while the user is typing in any input/textarea document.addEventListener('focusin', e => { if (e.target.matches('input, textarea, select')) inboxBusy = true; }); document.addEventListener('focusout', e => { if (e.target.matches('input, textarea, select')) inboxBusy = false; }); /** Burger toggle for the mobile nav. The button is hidden on desktop * via CSS, on mobile it expands #nav-buttons as a dropdown panel * positioned under the header. */ function toggleNavMenu() { $('nav-buttons')?.classList.toggle('menu-open'); } window.toggleNavMenu = toggleNavMenu; /** Tap-target on the header inbox count: switches to the compose view * if needed, then smooth-scrolls #inbox-section into view and briefly * highlights it so the user can't miss it after the scroll lands. * Same handler on desktop and mobile — desktop just scrolls a few px * if at all (the inbox is already on screen). */ function jumpToInbox() { if (!me) return; showView('inbox'); } window.jumpToInbox = jumpToInbox; // Tap outside the menu closes it document.addEventListener('click', (e) => { const nav = $('nav-buttons'); const burger = $('nav-burger'); if (!nav?.classList.contains('menu-open')) return; if (nav.contains(e.target) || burger?.contains(e.target)) return; nav.classList.remove('menu-open'); }); async function doChangePassword() { const current = $('cp-current').value; const newPw = $('cp-new').value; const newPw2 = $('cp-new2').value; const err = $('cp-err'); err.textContent = ''; if (!current || !newPw) { err.textContent = 'all fields required'; return; } if (newPw.length < 4) { err.textContent = 'new password too short'; return; } if (newPw !== newPw2) { err.textContent = 'new passwords don\'t match'; return; } if (current === newPw) { err.textContent = 'new password must differ from current'; return; } const res = await api('api/account/password', { method: 'POST', body: { current, new: newPw } }); if (res.ok) { toast('password changed'); $('cp-current').value = ''; $('cp-new').value = ''; $('cp-new2').value = ''; // After password change, route based on whether they have a secret const status = await refreshMe(); if (!status.has_secret) { showView('account'); toast('now set your client secret'); } else { showView('compose'); } } else { err.textContent = res.error || 'failed'; } } // ── Setup ── async function doSetup() { const username = $('setup-username').value.trim(); const password = $('setup-password').value; const superSecret = $('setup-super-secret').value; const err = $('setup-err'); err.textContent = ''; if (!username) { err.textContent = 'username required'; return; } if (password.length < 4) { err.textContent = 'password too short'; return; } if (superSecret.length < 12) { err.textContent = 'super-shared secret needs at least 12 chars'; return; } const res = await api('api/setup', { method: 'POST', body: { username, password, super_secret: superSecret } }); if (res.ok) { await refreshMe(); showView('account'); toast('admin created — now set your client secret'); } else { err.textContent = res.error || 'failed'; } } // ── Login ── async function doLogin() { const username = $('login-username').value.trim(); const password = $('login-password').value; const err = $('login-err'); err.textContent = ''; if (!username || !password) { err.textContent = 'username and password required'; return; } const res = await api('api/login', { method: 'POST', body: { username, password } }); if (res.ok) { const status = await refreshMe(); routePostLogin(status); } else { err.textContent = res.error || 'login failed'; } } // Decide which view to land on after a successful login / on initial load. // Order: forced password change → set client secret → compose. function routePostLogin(status) { if (!status.password_changed) { showView('change-password'); // Pre-fill the current-password input from what they just typed if // we're coming from the login form (otherwise it's empty) const lp = $('login-password'); if (lp && lp.value) { $('cp-current').value = lp.value; lp.value = ''; } toast('change your password before continuing'); } else if (!status.has_secret) { showView('account'); toast('set your client secret to start using the relay'); } else { const last = localStorage.getItem('relay-last-view') || 'compose'; showView(['compose', 'inbox', 'account', 'admin', 'chat', 'shopping'].includes(last) ? last : 'compose'); } } async function doLogout() { await api('api/logout', { method: 'POST' }); me = null; $('nav-user').textContent = ''; $('nav-admin').classList.add('hidden'); showView('login'); } async function refreshMe() { const status = await api('api/status'); if (status.logged_in) { me = { username: status.username, is_admin: status.is_admin, has_secret: status.has_secret, password_changed: status.password_changed, }; $('nav-user').innerHTML = `${escapeHtml(me.username)}` + (me.is_admin ? 'admin' : ''); $('nav-admin').classList.toggle('hidden', !me.is_admin); const n = status.inbox_count || 0; $('header-status').innerHTML = `${n} in inbox`; $('header-status').classList.toggle('has-unread', n > 0); } return status; } // ── Compose ── async function loadTenants() { const res = await api('api/tenants'); if (res.tenants) allTenants = res.tenants; } function populateRecipientPicker() { const hidden = $('compose-recipient'); const btn = $('compose-recipient-btn'); const label = $('compose-recipient-label'); const dd = $('compose-recipient-dropdown'); const field = $('compose-recipient-field'); if (!hidden || !btn || !dd) return; // Build the options list: self first, then all other tenants const options = []; if (me) options.push({ value: me.username, text: `${me.username} (you)` }); for (const t of allTenants) { if (me && t.username === me.username) continue; options.push({ value: t.username, text: t.username }); } // Default to self if (options.length && !hidden.value) { hidden.value = options[0].value; label.textContent = options[0].text; } // Render dropdown items dd.innerHTML = options.map(o => ` `).join(''); dd.querySelectorAll('.recipient-option').forEach(opt => { opt.addEventListener('mouseenter', () => opt.style.background = 'var(--raised)'); opt.addEventListener('mouseleave', () => opt.style.background = 'none'); opt.addEventListener('click', () => { hidden.value = opt.dataset.value; label.textContent = opt.textContent.trim(); dd.classList.add('hidden'); updateCcSelfVisibility(); }); }); // Toggle dropdown on button click btn.onclick = () => dd.classList.toggle('hidden'); // Close on outside click document.addEventListener('click', (e) => { if (!btn.contains(e.target) && !dd.contains(e.target)) dd.classList.add('hidden'); }); // Solo tenant → hide the picker if (field) field.classList.toggle('hidden', allTenants.length <= 1); updateCcSelfVisibility(); } let composeFiles = []; function updateCcSelfVisibility() { const wrap = $('compose-cc-self-wrap'); if (!wrap) return; const recipient = $('compose-recipient')?.value || ''; const isSelf = !recipient || (me && recipient === me.username); wrap.style.display = isSelf ? 'none' : ''; } function clearCompose() { $('compose-title').value = ''; $('compose-body').value = ''; $('compose-body').focus(); setComposeStatus(''); composeFiles = []; renderComposeAttachments(); } $('compose-attach-btn')?.addEventListener('click', () => $('compose-file')?.click()); $('compose-file')?.addEventListener('change', e => { for (const f of e.target.files) { if (composeFiles.some(x => x.name === f.name && x.size === f.size)) continue; composeFiles.push(f); } e.target.value = ''; renderComposeAttachments(); }); function formatSize(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } function fileIcon(name) { if (/\.(png|jpe?g|gif|webp|svg)$/i.test(name)) return 'ph-image'; if (/\.(mp3|ogg|wav|m4a)$/i.test(name)) return 'ph-music-note'; if (/\.pdf$/i.test(name)) return 'ph-file-pdf'; return 'ph-file'; } function renderComposeAttachments() { const el = $('compose-attachments'); if (!el) return; el.innerHTML = composeFiles.map((f, i) => `
${escapeHtml(f.name)} ${formatSize(f.size)}
`).join(''); el.querySelectorAll('.att-remove').forEach(btn => { btn.addEventListener('click', () => { composeFiles.splice(parseInt(btn.dataset.idx), 1); renderComposeAttachments(); }); }); } let composeStatusTimer = null; function setComposeStatus(text, kind = '') { clearTimeout(composeStatusTimer); const el = $('compose-status'); if (!el) return; el.textContent = text; el.className = 'compose-status-bar' + (kind ? ' ' + kind : ''); if (text) { el.classList.add('visible'); if (kind === 'ok') { composeStatusTimer = setTimeout(() => { el.classList.remove('visible'); }, 4000); } } else { el.classList.remove('visible'); } } function randomHex(n) { const a = new Uint8Array(n); crypto.getRandomValues(a); return Array.from(a, b => b.toString(16).padStart(2, '0')).join(''); } async function sendCompose() { const title = $('compose-title').value; let body = $('compose-body').value; const recipient = $('compose-recipient').value; if (!body.trim() && !composeFiles.length) { setComposeStatus('body or attachment required', 'err'); return; } const relayUid = randomHex(16); const hasFiles = composeFiles.length > 0; if (hasFiles) { setComposeStatus('uploading files...'); const hexNames = []; for (const f of composeFiles) { const form = new FormData(); form.append('file', f); form.append('relay_uid', relayUid); form.append('recipient', recipient || ''); try { const r = await fetch('api/attachments/upload', { method: 'POST', body: form }); const data = await r.json(); if (data.ok && data.filename) { const ext = (data.original || f.name).split('.').pop().toLowerCase(); const isImg = /^(png|jpe?g|gif|webp)$/.test(ext); const label = data.original || f.name; hexNames.push(isImg ? `![${label}](uploads/${data.filename})` : `[📄 ${label}](uploads/${data.filename})`); } } catch {} } if (hexNames.length) { body = body ? body + '\n\n' + hexNames.join('\n') : hexNames.join('\n'); } } setComposeStatus('sending...'); const ccSelf = $('compose-cc-self')?.checked && recipient; const res = await api('api/messages', { method: 'POST', body: { title, body, recipient, relay_uid: relayUid } }); if (res.ok) { // Send a copy to self tagged with recipient name if (ccSelf) { const ccTag = `#sent/${res.recipient}`; const ccBody = body ? body + '\n\n' + ccTag : ccTag; const ccUid = randomHex(16); await api('api/messages', { method: 'POST', body: { title, body: ccBody, relay_uid: ccUid, tags: `sent/${res.recipient}` } }); // Also copy attachments to the self-message if (hasFiles) { for (const f of composeFiles) { const form = new FormData(); form.append('file', f); form.append('relay_uid', ccUid); form.append('recipient', ''); try { await fetch('api/attachments/upload', { method: 'POST', body: form }); } catch {} } } } setComposeStatus(`sent to ${res.recipient}${ccSelf ? ' + copy to self' : ''}${hasFiles ? ` · ${composeFiles.length} file${composeFiles.length !== 1 ? 's' : ''}` : ''}`, 'ok'); $('compose-title').value = ''; $('compose-body').value = ''; $('compose-cc-self').checked = false; composeFiles = []; renderComposeAttachments(); $('compose-body').focus(); loadInbox(); loadOutbox(); refreshMe(); } else { setComposeStatus(res.error || 'failed', 'err'); } } // ── Inbox (counts only — content is encrypted for the hyprcrd client) ── let lastInboxJson = ''; async function loadInbox({ quiet = false } = {}) { const list = $('inbox-list'); if (!quiet) { list.innerHTML = `
`; } const res = await api('api/messages/inbox'); const messages = res?.messages || []; // In quiet mode, skip DOM update if nothing changed const json = JSON.stringify(messages.map(m => m.id + ':' + m.fetched)); if (quiet && json === lastInboxJson) return; lastInboxJson = json; $('inbox-count').textContent = messages.length; $('inbox-delete-all')?.classList.toggle('hidden', messages.length === 0); $('header-status').innerHTML = `${messages.length} in inbox`; $('header-status').classList.toggle('has-unread', messages.length > 0); if (!messages.length) { list.innerHTML = `
inbox empty
Messages from other tenants will show up here.
`; return; } list.innerHTML = messages.map(m => `
${escapeHtml(m.sender)}${m.fetched ? ' synced' : ' new'}
${m.title ? escapeHtml(m.title) : 'No preview'}
${escapeHtml(formatRelTime(m.created_at))}
`).join(''); } let lastOutboxJson = ''; async function loadOutbox({ quiet = false } = {}) { const list = $('outbox-list'); if (!list) return; const res = await api('api/messages/sent'); const messages = res?.messages || []; const json = JSON.stringify(messages.map(m => m.id + ':' + m.fetched)); if (quiet && json === lastOutboxJson) return; lastOutboxJson = json; $('outbox-count').textContent = messages.length; if (!messages.length) { list.innerHTML = `
no sent messages
Messages you send to other tenants will appear here.
`; return; } list.innerHTML = messages.map(m => `
to ${escapeHtml(m.recipient)}${m.fetched ? ' synced' : ' pending'}
${m.title ? escapeHtml(m.title) : 'No preview'}
${escapeHtml(formatRelTime(m.created_at))}
`).join(''); } // ── Shared buckets / collab inbox ── let lastCollabJson = ''; async function loadCollabInbox({ quiet = false } = {}) { const list = $('shared-list'); const countEl = $('shared-count'); if (!list) return; const res = await api('api/collab-inbox'); const items = res?.items || []; const json = JSON.stringify(items.map(c => c.card_uid + ':' + c.updated_at)); if (quiet && json === lastCollabJson) return; lastCollabJson = json; if (countEl) countEl.textContent = items.length; if (!items.length) { list.innerHTML = `
no shared bucket activity
Cards from collaborative buckets that you're a member of will appear here.
`; return; } list.innerHTML = items.map(c => `
${escapeHtml(c.bucket_name || c.bucket_uid)} ${c.mine ? 'you' : escapeHtml(c.author_username || '—')}
${c.title ? escapeHtml(c.title) : '(untitled)'}
${escapeHtml(formatRelTime(c.updated_at))}
`).join(''); } async function deleteMessage(id, btn, source) { inboxBusy = true; // Inline confirmation: replace the trash button with yes/no. // Event listeners are wired via JS (not inline onclick) to avoid // passing escaped HTML through attributes. const row = btn.closest('.inbox-item'); if (!row) return; const origHtml = btn.outerHTML; const wrap = document.createElement('span'); wrap.className = 'del-confirm'; wrap.style.cssText = 'display:inline-flex;align-items:center;gap:4px;font-family:var(--pixel);font-size:9px;'; wrap.innerHTML = ` delete? `; btn.replaceWith(wrap); wrap.querySelector('.del-yes').addEventListener('click', async () => { const res = await api('api/messages/' + id, { method: 'DELETE' }); inboxBusy = false; if (res?.ok) { if (row) { row.style.transition = 'opacity .2s'; row.style.opacity = '0'; setTimeout(() => row.remove(), 220); } if (source === 'outbox') loadOutbox(); else loadInbox(); refreshMe(); toast('message deleted', 'ok'); } else { toast(res?.error || 'delete failed', 'err'); } }); wrap.querySelector('.del-no').addEventListener('click', () => { inboxBusy = false; const temp = document.createElement('div'); temp.innerHTML = origHtml; wrap.replaceWith(temp.firstChild); }); } window.deleteMessage = deleteMessage; async function deleteAllMessages() { inboxBusy = true; try { const res = await api('api/messages/inbox'); const messages = res?.messages || []; if (!messages.length) return; if (!await relayConfirm(`Delete all ${messages.length} message${messages.length !== 1 ? 's' : ''}? This cannot be undone.`, { okLabel: 'Delete all' })) return; let ok = 0; for (const m of messages) { const r = await api('api/messages/' + m.id, { method: 'DELETE' }); if (r?.ok) ok++; } toast(`${ok} message${ok !== 1 ? 's' : ''} deleted`, 'ok'); loadInbox(); refreshMe(); } finally { inboxBusy = false; } } window.deleteAllMessages = deleteAllMessages; /** Lightweight relative-time formatter for the inbox. SQLite gives us * `YYYY-MM-DD HH:MM:SS` (UTC). Returns "2 min ago", "today 14:32", * or a full date for older messages. */ // ── Shopping (phone-side of the hyprcrd → relay list) ── // // We don't own the content — hyprcrd pushes the active card's body // to /api/shopping and we just render the unchecked "- [ ]" lines // as tappable rows. A tap POSTs to /api/shopping/check which flips // that exact line to "- [x]" on disk; hyprcrd's next pull sees it. let shoppingPollHandle = null; let shoppingBusy = false; function startShoppingPoll() { stopShoppingPoll(); // 4 s is responsive enough that edits on the desktop show up // while you're still holding the phone, and cheap enough that // an always-on tab costs basically nothing (single indexed // SELECT per poll). shoppingPollHandle = setInterval(() => { if (!document.hidden && !shoppingBusy) loadShopping(true); }, 4000); } function stopShoppingPoll() { if (shoppingPollHandle) { clearInterval(shoppingPollHandle); shoppingPollHandle = null; } } function shoppingErr(msg) { const el = $('shopping-err'); if (!el) return; if (!msg) { el.classList.add('hidden'); el.textContent = ''; return; } el.textContent = msg; el.classList.remove('hidden'); } function shoppingParseLines(body) { // Two node kinds end up on the phone: // heading — "# h1" … "###### h6" inside the body, used to // section the list (Obst / Backwaren / …) // item — unchecked "- [ ] …" lines (checked ones are hidden) // Then drop orphan headings (no open items before the next // heading or end of list) so empty section labels don't sit // around after everything under them is ticked off. const nodes = []; for (const line of String(body || '').split(/\r\n|\n|\r/)) { const hm = /^\s*(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line); if (hm) { nodes.push({ kind: 'heading', level: hm[1].length, text: hm[2] }); continue; } const im = /^\s*-\s*\[([ xX])\]\s*(.*)$/.exec(line); if (!im) continue; if (im[1].toLowerCase() === 'x') continue; nodes.push({ kind: 'item', text: im[2], line }); } const out = []; for (let i = 0; i < nodes.length; i++) { if (nodes[i].kind === 'heading') { let hasItem = false; for (let j = i + 1; j < nodes.length; j++) { if (nodes[j].kind === 'heading') break; if (nodes[j].kind === 'item') { hasItem = true; break; } } if (!hasItem) continue; } out.push(nodes[i]); } return out; } async function loadShopping(quiet = false) { if (!quiet) shoppingErr(''); const data = await api('api/shopping'); const title = $('shopping-title'); const list = $('shopping-items'); if (!list) return; if (!data?.ok) { if (!quiet) shoppingErr(data?.error || 'load failed'); return; } // Dedup by card_uid — belt and suspenders in case the relay DB // has stale rows from earlier schema migrations. const seen = new Set(); const lists = (data.lists || []).filter(l => { if (seen.has(l.card_uid)) return false; seen.add(l.card_uid); return true; }); if (!lists.length) { if (title) title.textContent = 'Shopping'; list.innerHTML = '
  • no active shopping lists
  • '; return; } if (title) title.textContent = 'Shopping'; list.innerHTML = ''; for (const entry of lists) { const nodes = shoppingParseLines(entry.body); const hasItems = nodes.some(n => n.kind === 'item'); // List box wrapper const box = document.createElement('li'); box.className = 'shopping-list-box'; const hdr = document.createElement('div'); hdr.className = 'shopping-list-header'; hdr.textContent = entry.title || '(untitled)'; box.appendChild(hdr); if (!hasItems) { const done = document.createElement('div'); done.className = 'shopping-list-done'; done.textContent = '\u2713 all done'; box.appendChild(done); } else { for (const n of nodes) { if (n.kind === 'heading') { const h = document.createElement('div'); h.className = 'shopping-heading h' + n.level; h.textContent = n.text; box.appendChild(h); } else { const row = document.createElement('div'); row.className = 'shopping-item'; row.textContent = n.text; row.dataset.line = n.line; row.dataset.uid = entry.card_uid; row.addEventListener('click', () => shoppingCheck(row)); box.appendChild(row); } } } list.appendChild(box); } } async function shoppingCheck(el) { if (shoppingBusy) return; shoppingBusy = true; el.classList.add('checked'); try { const res = await api('api/shopping/check', { method: 'POST', body: { card_uid: el.dataset.uid, line: el.dataset.line, checked: true }, }); if (!res?.ok) throw new Error(res?.error || 'check failed'); setTimeout(() => { shoppingBusy = false; loadShopping(true); }, 300); } catch (e) { shoppingBusy = false; el.classList.remove('checked'); shoppingErr(e.message); } } // Auto-refresh when the tab comes back into focus — same pattern as // the standalone page used to have, just without a manual button. document.addEventListener('visibilitychange', () => { if (document.hidden) return; if ($('view-shopping')?.classList.contains('hidden')) return; if (shoppingBusy) return; loadShopping(true); }); // ── Chat ── let chatActivePeer = null; let chatPollHandle = null; function startChatPoll() { stopChatPoll(); chatPollHandle = setInterval(() => { loadChatThreads(true); if (chatActivePeer) loadChatHistory(chatActivePeer, true); }, 5000); } function stopChatPoll() { if (chatPollHandle) { clearInterval(chatPollHandle); chatPollHandle = null; } } async function loadChatThreads(quiet = false) { const list = $('chat-thread-list'); if (!list) return; const threads = await api('api/chat/threads') || []; if (!Array.isArray(threads)) return; // If no threads and not quiet, show empty state if (!threads.length && !quiet) { list.innerHTML = `
    no conversations yet
    `; return; } if (!threads.length) return; list.innerHTML = threads.map(t => `
    ${escapeHtml(t.peer)}
    ${formatRelTime(t.last_at)}
    ${t.unread ? `${t.unread}` : ''}
    `).join(''); list.querySelectorAll('.chat-thread').forEach(el => { el.addEventListener('click', () => { chatActivePeer = el.dataset.peer; loadChatThreads(true); loadChatHistory(chatActivePeer); $('chat-input-bar')?.classList.remove('hidden'); $('chat-peer-name').textContent = chatActivePeer; $('chat-input')?.focus(); }); }); } async function loadChatHistory(peer, quiet = false) { const container = $('chat-messages'); if (!container) return; const messages = await api('api/chat/history?peer=' + encodeURIComponent(peer)) || []; if (!Array.isArray(messages)) return; const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 20; if (!messages.length) { container.innerHTML = `
    no messages yet
    `; return; } container.innerHTML = messages.map(m => `
    ${escapeHtml(m.body)}
    ${formatChatTime(m.created_at)}${m.mine && m.seen ? ' ' : ''}
    `).join(''); if (wasAtBottom || !quiet) { container.scrollTop = container.scrollHeight; } // Refresh thread list to clear unread badges if (!quiet) loadChatThreads(true); } async function sendChatMessage() { const input = $('chat-input'); if (!input || !chatActivePeer) return; const body = input.value.trim(); if (!body) return; input.value = ''; await api('api/chat/send', { method: 'POST', body: { body, recipient: chatActivePeer } }); loadChatHistory(chatActivePeer); loadChatThreads(true); } $('chat-send-btn')?.addEventListener('click', sendChatMessage); $('chat-input')?.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); } }); function formatChatTime(sqliteUtc) { if (!sqliteUtc) return ''; const t = new Date(sqliteUtc.replace(' ', 'T') + 'Z'); if (isNaN(t.getTime())) return sqliteUtc; const now = new Date(); const time = t.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); if (t.toDateString() === now.toDateString()) return time; const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); if (t.toDateString() === yesterday.toDateString()) return 'yesterday ' + time; return t.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time; } function formatRelTime(sqliteUtc) { if (!sqliteUtc) return ''; // Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z const t = new Date(sqliteUtc.replace(' ', 'T') + 'Z'); if (isNaN(t.getTime())) return sqliteUtc; const diff = (Date.now() - t.getTime()) / 1000; if (diff < 60) return 'just now'; if (diff < 3600) return Math.floor(diff / 60) + ' min ago'; if (diff < 86400) { const today = new Date(); const sameDay = today.toDateString() === t.toDateString(); if (sameDay) return 'today ' + t.toTimeString().slice(0, 5); return Math.floor(diff / 3600) + ' h ago'; } if (diff < 7 * 86400) return Math.floor(diff / 86400) + ' d ago'; return t.toISOString().slice(0, 10); } // ── Admin: users ── async function loadUsers() { const list = $('users-list'); list.innerHTML = `
    `; const res = await api('api/users'); if (res.error) { toast(res.error, 'err'); return; } const users = res.users || []; $('users-count').textContent = users.length; if (!users.length) { list.innerHTML = `
    no users yet
    Create the first one with the form on the right.
    `; return; } list.innerHTML = users.map(u => { const seenText = u.last_seen_at ? 'last seen ' + formatRelTime(u.last_seen_at) : 'never connected'; return `
    ${escapeHtml(u.username)} ${u.is_admin ? 'admin' : ''}
    created ${u.created_at} · ${seenText}
    ${u.username !== me?.username ? `` : ''}
    `; }).join(''); } async function createUser() { const username = $('new-username').value.trim(); const password = $('new-password').value; const isAdmin = $('new-is-admin').checked; const status = $('create-user-status'); status.textContent = 'creating...'; status.className = 'status'; const res = await api('api/users', { method: 'POST', body: { username, password, is_admin: isAdmin } }); if (res.ok) { status.textContent = 'created — they set their own client secret on first login'; status.className = 'status ok'; $('new-username').value = ''; $('new-password').value = ''; $('new-is-admin').checked = false; loadUsers(); setTimeout(() => { status.textContent = ''; }, 3000); } else { status.textContent = res.error || 'failed'; status.className = 'status err'; } } async function resetUserPassword(id, username) { const newPw = prompt(`new password for ${username} (min 4 chars):`); if (!newPw || newPw.length < 4) { toast('password too short', true); return; } const res = await api('api/users/' + id, { method: 'PATCH', body: { password: newPw } }); if (res.ok) toast('password updated'); else toast(res.error || 'failed', true); } async function deleteUser(id, username) { if (!await relayConfirm(`Delete user "${username}"? All their messages and attachments will be removed.`, { okLabel: 'Delete user' })) return; const res = await api('api/users/' + id, { method: 'DELETE' }); if (res.ok) { toast('user deleted'); loadUsers(); } else toast(res.error || 'failed', true); } // ── Account: client secret management ── let currentAccountSecret = null; async function loadAccountSecret() { setAccountStatus(''); const res = await api('api/account/secret'); if (res.has_secret) { currentAccountSecret = res.secret; $('account-current').classList.remove('hidden'); $('account-no-secret').classList.add('hidden'); $('account-secret-display').type = 'password'; $('account-secret-display').value = currentAccountSecret; $('account-secret-toggle').innerHTML = ' show'; } else { currentAccountSecret = null; $('account-current').classList.add('hidden'); $('account-no-secret').classList.remove('hidden'); } $('account-secret-input').value = ''; } function toggleAccountSecret() { const inp = $('account-secret-display'); const btn = $('account-secret-toggle'); if (inp.type === 'password') { inp.type = 'text'; btn.innerHTML = ' hide'; } else { inp.type = 'password'; btn.innerHTML = ' show'; } } async function copyAccountSecret() { if (!currentAccountSecret) return; try { await navigator.clipboard.writeText(currentAccountSecret); toast('secret copied to clipboard'); } catch { toast('copy failed — show and copy manually', true); } } async function saveAccountSecret() { const secret = $('account-secret-input').value; if (secret.length < 8) { setAccountStatus('secret needs at least 8 chars', 'err'); return; } setAccountStatus('saving...'); const res = await api('api/account/secret', { method: 'POST', body: { secret } }); if (res.ok) { setAccountStatus('saved', 'ok'); await loadAccountSecret(); await refreshMe(); setTimeout(() => setAccountStatus(''), 2000); } else { setAccountStatus(res.error || 'failed', 'err'); } } async function generateAccountSecret() { if (currentAccountSecret) { if (!await relayConfirm('Replace your current secret? All your hyprcrd clients will need the new value to connect.', { okLabel: 'Replace', danger: false })) return; } setAccountStatus('generating...'); const res = await api('api/account/secret', { method: 'POST', body: { generate: true } }); if (res.ok) { setAccountStatus('generated — copy it to your hyprcrd clients now', 'ok'); await loadAccountSecret(); await refreshMe(); // Auto-show the new secret so user can copy $('account-secret-display').type = 'text'; $('account-secret-toggle').innerHTML = ' hide'; } else { setAccountStatus(res.error || 'failed', 'err'); } } async function clearAccountSecret() { if (!await relayConfirm('Clear your client secret? All your hyprcrd clients will be disconnected and will need a new secret to reconnect.', { okLabel: 'Clear secret' })) return; const res = await api('api/account/secret', { method: 'DELETE' }); if (res.ok) { setAccountStatus('cleared', 'ok'); await loadAccountSecret(); await refreshMe(); } else { setAccountStatus(res.error || 'failed', 'err'); } } function setAccountStatus(text, kind = '') { const el = $('account-status'); if (!el) return; el.textContent = text; el.className = 'status ' + kind; } // ── Helpers ── function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); } // ── Init ── (async () => { const status = await api('api/status'); if (status.setup) { showView('setup'); } else if (!status.logged_in) { showView('login'); } else { await refreshMe(); routePostLogin(status); } })(); // Keyboard document.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { if (document.activeElement?.id === 'login-password') { e.preventDefault(); doLogin(); } if (document.activeElement?.id === 'setup-secret') { e.preventDefault(); doSetup(); } if (document.activeElement?.id === 'compose-body' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); sendCompose(); } } });