multi-tenant relay und card-push to user
This commit is contained in:
1 parent
7219bae655
commit
5b18c46ac2
22 files changed
+7453
-519
No files matched your search
@@ -11,6 +11,12 @@ const colors = $('ctx-colors');
|
||||
const buckets = $('ctx-buckets');
|
||||
const members = $('ctx-members');
|
||||
const tags = $('ctx-tags');
|
||||
const relayTenants = $('ctx-relay-tenants');
|
||||
|
||||
// Cached relay tenant list. Loaded on first push-relay submenu open
|
||||
// for the session, then reused. Refreshed if the user closes/reopens
|
||||
// the menu without success.
|
||||
let cachedRelayTenants = null;
|
||||
|
||||
export function hideContextMenu() { menu.classList.add('hidden'); }
|
||||
|
||||
@@ -38,7 +44,7 @@ export function showContextMenu(x, y, card) {
|
||||
<button class="ctx-item" data-action="due-date"><i class="ph ph-clock-countdown text-sm ${card.due_date ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${card.due_date ? 'due: ' + card.due_date : 'set due date'}</span></button>
|
||||
${card.due_date ? `<button class="ctx-item" data-action="due-done"><i class="ph ph-check-circle text-sm text-t-accent"></i><span class="font-pixel text-[10px]">mark done</span></button>` : ''}
|
||||
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">save .md</span></button>
|
||||
<button class="ctx-item" data-action="push-relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">push to relay</span></button>
|
||||
<button class="ctx-item" data-action="push-relay"><i class="ph ph-broadcast text-sm"></i><span class="font-pixel text-[10px]">push to relay</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'unfavorite' : 'favorite'}</span></button>
|
||||
<div class="ctx-sep"></div>
|
||||
<button class="ctx-item" data-action="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
@@ -162,7 +168,7 @@ export function showContextMenu(x, y, card) {
|
||||
menu.style.left = Math.min(x, innerWidth - menuRect.width - 8) + 'px';
|
||||
menu.style.top = (y + menuRect.height > innerHeight - 8 ? Math.max(8, y - menuRect.height) : y) + 'px';
|
||||
|
||||
const toggle = (panel) => { [colors, buckets, members, tags].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); };
|
||||
const toggle = (panel) => { [colors, buckets, members, tags, relayTenants].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); };
|
||||
|
||||
// Actions
|
||||
items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => {
|
||||
@@ -210,15 +216,7 @@ export function showContextMenu(x, y, card) {
|
||||
}
|
||||
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
|
||||
else if (a === 'push-relay') {
|
||||
hideContextMenu();
|
||||
const res = await api(`api/relay/push`, { method: 'POST', body: { card_id: card.id } });
|
||||
const msg = res?.ok ? 'pushed to relay' : (res?.error || 'push failed');
|
||||
const color = res?.ok ? 'var(--t-accent)' : 'var(--t-danger)';
|
||||
const n = document.createElement('div');
|
||||
n.style.cssText = `position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ${color};border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);`;
|
||||
n.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:${color};display:flex;align-items:center;gap:6px;"><i class="ph ph-broadcast"></i>${msg}</div>`;
|
||||
document.body.appendChild(n);
|
||||
setTimeout(() => { n.style.transition='opacity .3s'; n.style.opacity='0'; setTimeout(() => n.remove(), 350); }, 3000);
|
||||
await openRelayTenantSubmenu(card);
|
||||
}
|
||||
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
|
||||
else if (a === 'color') toggle(colors);
|
||||
@@ -252,3 +250,87 @@ export function showContextMenu(x, y, card) {
|
||||
members.querySelectorAll('[data-ctx-member]').forEach(btn => btn.addEventListener('click', e => { e.stopPropagation(); if (e.target.closest('[data-ctx-kick]')) return; hideContextMenu(); window._openDetail(btn.dataset.ctxMember); }));
|
||||
members.querySelectorAll('[data-ctx-kick]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); await api(`api/cards/${btn.dataset.ctxKick}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }));
|
||||
}
|
||||
|
||||
// ── Push to relay submenu (cross-tenant recipient picker) ──
|
||||
|
||||
async function openRelayTenantSubmenu(card) {
|
||||
// Show submenu immediately with a loading state, then fetch the
|
||||
// tenant list (cached after first successful load).
|
||||
const toggle = (panel) => {
|
||||
[colors, buckets, members, tags, relayTenants].forEach(p => { if (p !== panel) p.classList.add('hidden'); });
|
||||
panel.classList.remove('hidden');
|
||||
};
|
||||
toggle(relayTenants);
|
||||
|
||||
if (!cachedRelayTenants) {
|
||||
relayTenants.innerHTML = '<div class="px-3 py-2 text-[10px] text-t-dim font-pixel">loading tenants...</div>';
|
||||
try {
|
||||
const res = await fetch('api/relay/tenants').then(r => r.json());
|
||||
if (!res.ok) {
|
||||
relayTenants.innerHTML = `<div class="px-3 py-2 text-[10px] text-t-danger font-pixel break-words">${res.error || 'failed'}</div>`;
|
||||
return;
|
||||
}
|
||||
cachedRelayTenants = res;
|
||||
} catch (err) {
|
||||
relayTenants.innerHTML = `<div class="px-3 py-2 text-[10px] text-t-danger font-pixel break-words">${err.message}</div>`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
renderRelayTenants(card);
|
||||
}
|
||||
|
||||
function renderRelayTenants(card) {
|
||||
const list = cachedRelayTenants?.tenants || [];
|
||||
const self = cachedRelayTenants?.self || '';
|
||||
if (!list.length) {
|
||||
relayTenants.innerHTML = '<div class="px-3 py-2 text-[10px] text-t-dim font-pixel italic">no tenants</div>';
|
||||
return;
|
||||
}
|
||||
// Self first, then others alphabetical
|
||||
const ordered = [
|
||||
...list.filter(t => t.username === self),
|
||||
...list.filter(t => t.username !== self),
|
||||
];
|
||||
relayTenants.innerHTML = ordered.map(t => {
|
||||
const isSelf = t.username === self;
|
||||
const icon = isSelf ? 'ph-user' : 'ph-user-circle';
|
||||
const label = isSelf ? `${t.username} (you)` : t.username;
|
||||
return `<button class="ctx-item" data-relay-recipient="${t.username}"><i class="ph ${icon} text-sm"></i><span class="font-pixel text-[10px]">${label}</span></button>`;
|
||||
}).join('');
|
||||
|
||||
relayTenants.querySelectorAll('[data-relay-recipient]').forEach(btn => {
|
||||
btn.addEventListener('click', async e => {
|
||||
e.stopPropagation();
|
||||
const recipient = btn.dataset.relayRecipient;
|
||||
hideContextMenu();
|
||||
const res = await api('api/relay/push', { method: 'POST', body: { card_id: card.id, recipient } });
|
||||
relayPushToast(res, recipient);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function relayPushToast(res, recipient) {
|
||||
const ok = res?.ok;
|
||||
const msg = ok
|
||||
? (recipient && recipient !== cachedRelayTenants?.self ? `pushed to ${recipient}` : 'pushed to relay')
|
||||
: (res?.error || 'push failed');
|
||||
const color = ok ? 'var(--t-accent)' : 'var(--t-danger)';
|
||||
if (!ok) {
|
||||
// Dump every diagnostic the backend gave us so we can see the wire-level
|
||||
// truth instead of guessing why the relay said no
|
||||
console.group('%c[relay push failed]', 'color:#fb7185;font-weight:bold');
|
||||
console.log('error:', res?.error);
|
||||
if (res?.relay_url) console.log('url:', res.relay_url);
|
||||
if (res?.sent_username) console.log('sent username:', res.sent_username);
|
||||
if (res?.response_body) console.log('response body:', res.response_body);
|
||||
if (res?.curl_debug) console.log('curl debug:\n' + res.curl_debug);
|
||||
console.groupEnd();
|
||||
}
|
||||
const n = document.createElement('div');
|
||||
n.style.cssText = `position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid ${color};border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:340px;`;
|
||||
const hint = ok ? '' : '<div style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-dim);margin-top:4px;">see browser console for wire-level debug</div>';
|
||||
n.innerHTML = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:${color};display:flex;align-items:center;gap:6px;"><i class="ph ph-broadcast"></i>${esc(msg)}</div>${hint}`;
|
||||
document.body.appendChild(n);
|
||||
setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, ok ? 3000 : 6000);
|
||||
}
|
||||
Reference in new issue
Block a user