- sync: the post-sync toast lumped relay pulls into the CalDAV stats and always labelled the banner "CalDAV". Split into two separate toasts (CalDAV cloud-arrow + Relay broadcast icon), each only shown when its numbers are non-zero. Banners now stack vertically so both can appear together on a sync that pulled on both sides. - relay UI: the session chat view was 1:1-only. Group rooms (which the client API already supports) now surface as first-class threads in the sidebar — sorted chronologically with 1:1 peers, tagged "ROOM", with a users-three icon. Sending posts to `room_uid` instead of `recipient`; history shows the sender name above each incoming message so it's clear who's talking in the room. Relay backend: `/api/chat/threads` returns rooms alongside 1:1 threads; `/api/chat/history` accepts `?room_uid=`; `/api/chat/send` accepts `room_uid` in the body. - tests: bump "replace all updates card bodies" poll budget from 4s to 10s and poll both updated cards. Flaky on cold runs because a fresh PHP worker + SQLite WAL warm-up plus two sequential PATCH calls can just barely exceed 4 seconds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1406 lines
56 KiB
JavaScript
1406 lines
56 KiB
JavaScript
// 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<boolean>. 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 = `<i class="ph ${icon} toast-icon"></i><span class="toast-msg"></span>`;
|
|
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', 'read'].forEach(v => {
|
|
$('view-' + v)?.classList.add('hidden');
|
|
});
|
|
$('view-' + name)?.classList.remove('hidden');
|
|
|
|
if (['compose', 'inbox', 'account', 'admin', 'chat', 'shopping', 'read'].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-read')?.classList.toggle('active', name === 'read');
|
|
$('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();
|
|
}
|
|
if (name === 'read') {
|
|
loadReadList();
|
|
// Close any open article when switching into the list view.
|
|
closeReader();
|
|
}
|
|
// 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', 'read'].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 < 10) { err.textContent = 'new password must be at least 10 chars'; 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 < 10) { err.textContent = 'password must be at least 10 chars'; 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', 'read'].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 = `<span>${escapeHtml(me.username)}</span>` + (me.is_admin ? '<span class="badge">admin</span>' : '');
|
|
$('nav-admin').classList.toggle('hidden', !me.is_admin);
|
|
const n = status.inbox_count || 0;
|
|
$('header-status').innerHTML = `<i class="ph ph-tray"></i><span>${n} in inbox</span>`;
|
|
$('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 => `
|
|
<button class="recipient-option" data-value="${escapeHtml(o.value)}" style="display:block;width:100%;text-align:left;padding:7px 12px;font-size:12px;font-family:var(--mono);color:var(--text);background:none;border:none;cursor:pointer;transition:background .1s;">
|
|
<i class="ph ph-user-circle" style="font-size:12px;color:var(--accent2);margin-right:6px;"></i>${escapeHtml(o.text)}
|
|
</button>
|
|
`).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) => `
|
|
<div class="compose-att">
|
|
<i class="ph ${fileIcon(f.name)} att-icon"></i>
|
|
<span>${escapeHtml(f.name)}</span>
|
|
<span class="att-size">${formatSize(f.size)}</span>
|
|
<button class="att-remove" data-idx="${i}" aria-label="remove"><i class="ph ph-x"></i></button>
|
|
</div>
|
|
`).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})`);
|
|
}
|
|
} 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 = `
|
|
<div class="skeleton-row"><div class="skel tall"></div></div>
|
|
<div class="skeleton-row"><div class="skel short"></div></div>
|
|
<div class="skeleton-row"><div class="skel"></div></div>
|
|
`;
|
|
}
|
|
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 = `<i class="ph ph-tray"></i><span>${messages.length} in inbox</span>`;
|
|
$('header-status').classList.toggle('has-unread', messages.length > 0);
|
|
|
|
if (!messages.length) {
|
|
list.innerHTML = `
|
|
<div class="empty-state">
|
|
<i class="ph ph-tray icon"></i>
|
|
<div class="label">inbox empty</div>
|
|
<div class="hint">Messages from other tenants will show up here.</div>
|
|
</div>`;
|
|
return;
|
|
}
|
|
list.innerHTML = messages.map(m => `
|
|
<div class="inbox-item" data-id="${m.id}">
|
|
<div class="content">
|
|
<div class="from"><i class="ph ph-user-circle"></i> ${escapeHtml(m.sender)}${m.fetched ? ' <span class="badge-synced"><i class="ph ph-check"></i> synced</span>' : ' <span class="badge-new">new</span>'}</div>
|
|
<div class="title">${m.title ? escapeHtml(m.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">No preview</span>'}</div>
|
|
</div>
|
|
<div class="meta">${escapeHtml(formatRelTime(m.created_at))}</div>
|
|
<button class="del" onclick="deleteMessage(${m.id}, this)" aria-label="delete"><i class="ph ph-trash"></i></button>
|
|
</div>
|
|
`).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 = `
|
|
<div class="empty-state">
|
|
<i class="ph ph-paper-plane-tilt icon"></i>
|
|
<div class="label">no sent messages</div>
|
|
<div class="hint">Messages you send to other tenants will appear here.</div>
|
|
</div>`;
|
|
return;
|
|
}
|
|
list.innerHTML = messages.map(m => `
|
|
<div class="inbox-item" data-id="${m.id}">
|
|
<div class="content">
|
|
<div class="from"><i class="ph ph-arrow-right"></i> to ${escapeHtml(m.recipient)}${m.fetched ? ' <span class="badge-synced"><i class="ph ph-check"></i> synced</span>' : ' <span class="badge-new">pending</span>'}</div>
|
|
<div class="title">${m.title ? escapeHtml(m.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">No preview</span>'}</div>
|
|
</div>
|
|
<div class="meta">${escapeHtml(formatRelTime(m.created_at))}</div>
|
|
<button class="del" onclick="deleteMessage(${m.id}, this, 'outbox')" aria-label="delete"><i class="ph ph-trash"></i></button>
|
|
</div>
|
|
`).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 = `
|
|
<div class="empty-state">
|
|
<i class="ph ph-users-three icon"></i>
|
|
<div class="label">no shared bucket activity</div>
|
|
<div class="hint">Cards from collaborative buckets that you're a member of will appear here.</div>
|
|
</div>`;
|
|
return;
|
|
}
|
|
list.innerHTML = items.map(c => `
|
|
<div class="inbox-item">
|
|
<div class="content">
|
|
<div class="from">
|
|
<span class="shared-bucket-chip" style="border-left:3px solid ${escapeHtml(c.bucket_color || '#38bdf8')};padding-left:6px;">${escapeHtml(c.bucket_name || c.bucket_uid)}</span>
|
|
<span style="color:var(--dim);margin-left:6px;">${c.mine ? 'you' : escapeHtml(c.author_username || '—')}</span>
|
|
</div>
|
|
<div class="title">${c.title ? escapeHtml(c.title) : '<span style="color:var(--dim);font-style:italic;font-weight:normal;">(untitled)</span>'}</div>
|
|
</div>
|
|
<div class="meta">${escapeHtml(formatRelTime(c.updated_at))}</div>
|
|
</div>
|
|
`).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 = `
|
|
<span style="color:var(--danger);">delete?</span>
|
|
<button class="del-yes" style="background:var(--danger);color:var(--bg);border:none;border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">yes</button>
|
|
<button class="del-no" style="background:none;border:1px solid var(--border);color:var(--muted);border-radius:var(--radius);padding:2px 8px;cursor:pointer;font-family:var(--pixel);font-size:9px;">no</button>
|
|
`;
|
|
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 = '<li class="empty">no active shopping lists</li>';
|
|
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 ──
|
|
//
|
|
// Two thread kinds live side by side: 1:1 (identified by peer username)
|
|
// and group rooms (identified by room_uid). The UI keeps a single
|
|
// "active thread" descriptor and switches the fetch / send shape based
|
|
// on which field is populated.
|
|
|
|
let chatActive = null; // { kind: 'peer' | 'room', peer?, roomUid?, label }
|
|
let chatPollHandle = null;
|
|
|
|
function startChatPoll() {
|
|
stopChatPoll();
|
|
chatPollHandle = setInterval(() => {
|
|
loadChatThreads(true);
|
|
if (chatActive) loadChatHistory(chatActive, true);
|
|
}, 5000);
|
|
}
|
|
function stopChatPoll() {
|
|
if (chatPollHandle) { clearInterval(chatPollHandle); chatPollHandle = null; }
|
|
}
|
|
|
|
function isSameActive(a, b) {
|
|
if (!a || !b) return false;
|
|
return a.kind === b.kind && (a.peer === b.peer) && (a.roomUid === b.roomUid);
|
|
}
|
|
|
|
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 (!threads.length && !quiet) {
|
|
list.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no conversations yet</div></div>`;
|
|
return;
|
|
}
|
|
if (!threads.length) return;
|
|
|
|
list.innerHTML = threads.map(t => {
|
|
const isRoom = !!t.room_uid;
|
|
const desc = isRoom
|
|
? { kind: 'room', roomUid: t.room_uid, label: t.name || t.room_uid }
|
|
: { kind: 'peer', peer: t.peer, label: t.peer };
|
|
const active = isSameActive(chatActive, desc);
|
|
const icon = isRoom ? 'ph-users-three' : 'ph-user-circle';
|
|
const tag = isRoom ? 'room' : null;
|
|
const attrs = isRoom
|
|
? `data-kind="room" data-room-uid="${escapeHtml(t.room_uid)}" data-label="${escapeHtml(desc.label)}"`
|
|
: `data-kind="peer" data-peer="${escapeHtml(t.peer)}"`;
|
|
return `
|
|
<div class="chat-thread ${active ? 'active' : ''}" ${attrs}>
|
|
<i class="ph ${icon}" style="font-size:16px;color:var(--accent2);flex-shrink:0;"></i>
|
|
<div style="flex:1;min-width:0;">
|
|
<div class="peer-name">${escapeHtml(desc.label)}${tag ? ` <span style="font-family:var(--pixel);font-size:8px;color:var(--dim);text-transform:uppercase;letter-spacing:0.5px;margin-left:4px;">${tag}</span>` : ''}</div>
|
|
<div class="thread-time">${escapeHtml(formatRelTime(t.last_at))}</div>
|
|
</div>
|
|
${t.unread ? `<span class="unread-badge">${t.unread}</span>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
|
|
list.querySelectorAll('.chat-thread').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
if (el.dataset.kind === 'room') {
|
|
chatActive = { kind: 'room', roomUid: el.dataset.roomUid, label: el.dataset.label };
|
|
} else {
|
|
chatActive = { kind: 'peer', peer: el.dataset.peer, label: el.dataset.peer };
|
|
}
|
|
loadChatThreads(true);
|
|
loadChatHistory(chatActive);
|
|
$('chat-input-bar')?.classList.remove('hidden');
|
|
$('chat-peer-name').textContent = chatActive.label;
|
|
$('chat-input')?.focus();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadChatHistory(active, quiet = false) {
|
|
const container = $('chat-messages');
|
|
if (!container || !active) return;
|
|
const qs = active.kind === 'room'
|
|
? 'room_uid=' + encodeURIComponent(active.roomUid)
|
|
: 'peer=' + encodeURIComponent(active.peer);
|
|
const messages = await api('api/chat/history?' + qs) || [];
|
|
if (!Array.isArray(messages)) return;
|
|
|
|
const wasAtBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 20;
|
|
|
|
if (!messages.length) {
|
|
container.innerHTML = `<div class="chat-empty"><i class="ph ph-chat-circle-dots" style="font-size:24px;"></i><div>no messages yet</div></div>`;
|
|
return;
|
|
}
|
|
|
|
// Group rooms need the sender's name visible above each incoming
|
|
// message (there isn't one single peer — it could be anyone in the
|
|
// room). 1:1 stays unlabeled for a tighter look.
|
|
const showSender = active.kind === 'room';
|
|
container.innerHTML = messages.map(m => `
|
|
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
|
|
${showSender && !m.mine ? `<div class="msg-from" style="font-family:var(--pixel);font-size:9px;color:var(--accent2);margin-bottom:2px;">${escapeHtml(m.from)}</div>` : ''}
|
|
<div>${escapeHtml(m.body)}</div>
|
|
<div class="msg-time">${formatChatTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
if (wasAtBottom || !quiet) {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
if (!quiet) loadChatThreads(true);
|
|
}
|
|
|
|
async function sendChatMessage() {
|
|
const input = $('chat-input');
|
|
if (!input || !chatActive) return;
|
|
const body = input.value.trim();
|
|
if (!body) return;
|
|
input.value = '';
|
|
const payload = chatActive.kind === 'room'
|
|
? { body, room_uid: chatActive.roomUid }
|
|
: { body, recipient: chatActive.peer };
|
|
await api('api/chat/send', { method: 'POST', body: payload });
|
|
loadChatHistory(chatActive);
|
|
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 = `
|
|
<div class="skeleton-row"><div class="skel tall"></div></div>
|
|
<div class="skeleton-row"><div class="skel short"></div></div>
|
|
<div class="skeleton-row"><div class="skel"></div></div>
|
|
`;
|
|
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 = `
|
|
<div class="empty-state">
|
|
<i class="ph ph-users icon"></i>
|
|
<div class="label">no users yet</div>
|
|
<div class="hint">Create the first one with the form on the right.</div>
|
|
</div>`;
|
|
return;
|
|
}
|
|
list.innerHTML = users.map(u => {
|
|
const seenText = u.last_seen_at ? 'last seen ' + formatRelTime(u.last_seen_at) : 'never connected';
|
|
return `
|
|
<div class="user-row" data-id="${u.id}">
|
|
<i class="ph ph-user-circle user-icon"></i>
|
|
<div class="info">
|
|
<div class="name">${escapeHtml(u.username)} ${u.is_admin ? '<span class="badge">admin</span>' : ''}</div>
|
|
<div class="meta">created ${u.created_at} · ${seenText}</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn btn-secondary" onclick="resetUserPassword(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-key"></i> reset pw</button>
|
|
${u.username !== me?.username ? `<button class="btn btn-danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.username)}')"><i class="ph ph-trash"></i> delete</button>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).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 10 chars):`);
|
|
if (!newPw || newPw.length < 10) { 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 = '<i class="ph ph-eye"></i> 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 = '<i class="ph ph-eye-slash"></i> hide';
|
|
} else {
|
|
inp.type = 'password';
|
|
btn.innerHTML = '<i class="ph ph-eye"></i> 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 = '<i class="ph ph-eye-slash"></i> 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(); }
|
|
}
|
|
});
|
|
|
|
// ── Read view: list + zen reader ──────────────────────────────────
|
|
//
|
|
// The hyprcrd client pushes cards here when the user toggles "Read later"
|
|
// on a card. This view renders the list, and the reader overlay renders
|
|
// the markdown in a mobile-first, distraction-free column. Images in the
|
|
// body are rewritten to the relay's session-authed image endpoint.
|
|
|
|
let readCurrentUid = null;
|
|
|
|
async function loadReadList() {
|
|
const list = $('read-items');
|
|
const empty = $('read-empty');
|
|
if (!list) return;
|
|
list.innerHTML = '';
|
|
try {
|
|
const res = await api('api/read');
|
|
const items = res?.items || [];
|
|
if (!items.length) {
|
|
empty.classList.remove('hidden');
|
|
return;
|
|
}
|
|
empty.classList.add('hidden');
|
|
list.innerHTML = items.map(it => {
|
|
const title = it.title?.trim() || '(untitled)';
|
|
const thumb = it.hero_filename
|
|
? `<div class="read-item-thumb" style="background-image:url('api/read-attachments/${encodeURIComponent(it.relay_uid)}/${encodeURIComponent(it.hero_filename)}');"></div>`
|
|
: `<div class="read-item-thumb read-item-thumb-placeholder"><i class="ph ph-book-open"></i></div>`;
|
|
return `
|
|
<li class="read-item" data-uid="${escapeHtml(it.relay_uid)}">
|
|
${thumb}
|
|
<div class="read-item-body">
|
|
<div class="read-item-title">${escapeHtml(title)}</div>
|
|
<div class="read-item-meta">${escapeHtml(formatRelTime(it.updated_at))}</div>
|
|
</div>
|
|
</li>`;
|
|
}).join('');
|
|
list.querySelectorAll('.read-item').forEach(el => {
|
|
el.addEventListener('click', () => openReader(el.dataset.uid));
|
|
});
|
|
} catch (e) {
|
|
list.innerHTML = `<li style="color:var(--danger);font-size:12px;">Failed to load: ${escapeHtml(e.message)}</li>`;
|
|
}
|
|
}
|
|
|
|
async function openReader(uid) {
|
|
const reader = $('read-reader');
|
|
const article = $('read-article');
|
|
if (!reader || !article) return;
|
|
readCurrentUid = uid;
|
|
article.innerHTML = '<p style="color:var(--dim);">loading…</p>';
|
|
reader.classList.remove('hidden');
|
|
reader.scrollTop = 0;
|
|
try {
|
|
const res = await api('api/read/' + encodeURIComponent(uid));
|
|
if (res?.error) throw new Error(res.error);
|
|
article.innerHTML = renderReadArticle(res);
|
|
} catch (e) {
|
|
article.innerHTML = `<p style="color:var(--danger);">Failed to load: ${escapeHtml(e.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
function closeReader() {
|
|
readCurrentUid = null;
|
|
$('read-reader')?.classList.add('hidden');
|
|
}
|
|
|
|
function renderReadArticle(item) {
|
|
const title = item.title?.trim() || '(untitled)';
|
|
const rawBody = item.body || '';
|
|
// Hero: first uploads/<hex>.<ext> reference in body. Rewrite to the
|
|
// session-authed relay endpoint so the <img src> resolves server-side
|
|
// against the tenant's own read_attachments rows.
|
|
const uid = encodeURIComponent(item.relay_uid);
|
|
const bodyForRender = rawBody.replace(
|
|
/uploads\/([a-f0-9]+\.[a-z0-9]+)/gi,
|
|
(_m, fn) => `api/read-attachments/${uid}/${encodeURIComponent(fn)}`
|
|
);
|
|
const hero = item.hero_filename
|
|
? `<img class="read-hero" src="api/read-attachments/${uid}/${encodeURIComponent(item.hero_filename)}" alt="">`
|
|
: '';
|
|
const date = item.updated_at ? formatRelTime(item.updated_at) : '';
|
|
return `
|
|
${hero}
|
|
<h1 class="read-title">${escapeHtml(title)}</h1>
|
|
<div class="read-date">${escapeHtml(date)}</div>
|
|
${renderMarkdown(bodyForRender)}
|
|
`;
|
|
}
|
|
|
|
// Tiny inline markdown renderer. Covers the cases hyprcrd cards actually
|
|
// use: paragraphs, headings, bold/italic, inline code, links, bullet +
|
|
// numbered lists, blockquotes, hr, fenced code blocks, inline images.
|
|
// We do NOT pull a full library in — this matches the UI's existing
|
|
// minimal-deps stance (fonts + phosphor, nothing else).
|
|
function renderMarkdown(src) {
|
|
if (!src) return '';
|
|
const placeholders = [];
|
|
const stash = (html) => { placeholders.push(html); return `\u0000${placeholders.length - 1}\u0000`; };
|
|
|
|
// Fenced code
|
|
src = src.replace(/```(\w+)?\n([\s\S]*?)```/g, (_m, _lang, code) =>
|
|
stash(`<pre><code>${escapeHtml(code)}</code></pre>`));
|
|
// Inline code
|
|
src = src.replace(/`([^`\n]+)`/g, (_m, c) => stash(`<code>${escapeHtml(c)}</code>`));
|
|
|
|
// Escape remaining HTML
|
|
src = escapeHtml(src);
|
|
|
|
// Restore stashed code first (before we mangle further)
|
|
src = src.replace(/\u0000(\d+)\u0000/g, (_m, i) => placeholders[Number(i)]);
|
|
|
|
// Images: 
|
|
src = src.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt, url) =>
|
|
`<img src="${escapeAttr(url)}" alt="${escapeAttr(alt)}">`);
|
|
// Links: [text](url)
|
|
src = src.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) =>
|
|
`<a href="${escapeAttr(url)}" target="_blank" rel="noopener">${text}</a>`);
|
|
|
|
// Headings (## etc.), line-anchored
|
|
src = src.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
|
|
src = src.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
|
|
src = src.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>');
|
|
|
|
// HR
|
|
src = src.replace(/^---+$/gm, '<hr>');
|
|
|
|
// Blockquotes: consecutive "> " lines collapse into one <blockquote>
|
|
src = src.replace(/(^|\n)((?:> [^\n]*\n?)+)/g, (_m, pre, block) => {
|
|
const inner = block.replace(/^> ?/gm, '').trim();
|
|
return `${pre}<blockquote>${inner.replace(/\n/g, '<br>')}</blockquote>\n`;
|
|
});
|
|
|
|
// Lists: grouped runs of "- " / "* " / "N." items
|
|
src = src.replace(/(^|\n)((?:[-*]\s+[^\n]+\n?)+)/g, (_m, pre, block) => {
|
|
const items = block.trim().split(/\n/).map(l => l.replace(/^[-*]\s+/, '')).map(i => `<li>${i}</li>`).join('');
|
|
return `${pre}<ul>${items}</ul>\n`;
|
|
});
|
|
src = src.replace(/(^|\n)((?:\d+\.\s+[^\n]+\n?)+)/g, (_m, pre, block) => {
|
|
const items = block.trim().split(/\n/).map(l => l.replace(/^\d+\.\s+/, '')).map(i => `<li>${i}</li>`).join('');
|
|
return `${pre}<ol>${items}</ol>\n`;
|
|
});
|
|
|
|
// Bold + italic (do bold first to avoid partial capture)
|
|
src = src.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
|
src = src.replace(/(?<!\w)\*([^*\n]+)\*(?!\w)/g, '<em>$1</em>');
|
|
|
|
// Paragraphs: wrap any run of non-block-level lines. Block-level tags
|
|
// we've already emitted stay as-is.
|
|
const blocks = src.split(/\n{2,}/);
|
|
return blocks.map(b => {
|
|
const t = b.trim();
|
|
if (!t) return '';
|
|
if (/^<(h[1-6]|ul|ol|pre|blockquote|hr|img)/.test(t)) return t;
|
|
return `<p>${t.replace(/\n/g, '<br>')}</p>`;
|
|
}).join('\n');
|
|
}
|
|
|
|
function escapeAttr(s) {
|
|
return String(s ?? '').replace(/["&<>]/g, c => ({ '"': '"', '&': '&', '<': '<', '>': '>' }[c]));
|
|
}
|
|
|
|
$('read-back')?.addEventListener('click', closeReader);
|
|
$('read-delete')?.addEventListener('click', async () => {
|
|
if (!readCurrentUid) return;
|
|
if (!await relayConfirm('Remove this item from your read list?', { okLabel: 'Remove', danger: true })) return;
|
|
const uid = readCurrentUid;
|
|
try {
|
|
await api('api/read/' + encodeURIComponent(uid), { method: 'DELETE' });
|
|
closeReader();
|
|
loadReadList();
|
|
toast('removed');
|
|
} catch (e) {
|
|
toast(e.message || 'failed', true);
|
|
}
|
|
});
|
|
|
|
// Browser back should close the reader first, then leave the view.
|
|
window.addEventListener('keydown', e => {
|
|
if (e.key === 'Escape' && readCurrentUid) {
|
|
e.preventDefault();
|
|
closeReader();
|
|
}
|
|
});
|