Files
hyprcrd/public/js/modules/context-menu.js
T
thandClaude Opus 4.6 9b4869812f join: merge multi-selected cards into one (right-click + toolbar)
Right-click on a card that's part of a ≥2-card selection now offers
"Join N cards" at the top of the context menu, and the same action
sits next to "Group" in the bottom select-bar.

Title strategy:
* sequential ctrl-click selection → first selected wins
* bulk gesture (rubber-band) → longest title wins

The bulk vs sequential distinction is tracked via a new
`state.selectionWasBulk` flag set in board.js when the rubber-band
becomes active and cleared in clearSelection() and after a successful
join. ctrl-clicks alone don't touch it, so bulk + ctrl-click hybrid
selections stay bulk.

Body format: list of every original title as `## heading` lines, then
each card's body separated by `---`. The winner keeps its color, tags,
bucket, due_date, parent_id; only the body is rewritten. Losers are
deleted via DELETE /api/cards/{id}.

Undo: a single pushUndo entry restores the winner's original body and
recreates every loser via POST + due_date PATCH (since POST doesn't
take due_date). Ctrl+Z reverts the whole join.

Implementation:
* state.js: new selectionWasBulk boolean in state.
* board.js: rubber-band onMove sets state.selectionWasBulk = true on
  first activation; clearSelection clears it. New select-join click
  handler dynamic-imports joinSelectedCards from context-menu.js to
  keep the dependency lazy.
* context-menu.js: joinSelectedCards is now exported. Inserts
  data-action="join" into the menu when the right-clicked card is in
  a ≥2-card selection. Action handler dispatches to joinSelectedCards
  before falling through to the regular if-chain so it can early-return.
* index.php: new #select-join button in #select-bar with ph-arrows-merge
  icon, sits between Group and Bucket.

Tests (public/plugins/tests/card-join.test.js, registered in tests.js):
* visibility: shows for ≥2 selected with right-clicked card in the
  selection; hidden for solo selection; hidden when right-click target
  isn't selected.
* sequential winner: first ctrl-clicked card survives, others deleted.
* merged body lists every title at the top and contains every body.
* selection cleared after a successful join.
* bulk winner: longest title survives regardless of insertion order.
* undo: Ctrl+Z restores winner body + recreates losers.

undo.test.js race-fix:
* New waitForStateCard(id, predicate, timeout=2000) helper polls
  state.cards every 30ms until the in-memory state reflects the
  expected change. Replaces fragile fixed `sleep(300)` waits between
  chained user actions where the second action reads from state.cards
  before the first action's _reloadAll has landed.
* Multi-undo color test: between the two color changes, wait for
  state.cards to actually contain the pink color before doing the
  second rightClick. Without this the second action's contextmenu
  handler reads the stale card and pushes the wrong undo snapshot,
  collapsing both undo entries onto the original color.
* Due_date undo test: after PATCHing due_date directly via API, wait
  for state.cards to reflect it before opening the editor — the
  editor's snapshot for the undo entry reads from state.cards.find()
  and would otherwise capture due_date=null.

chore: bump version to 0.9.5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:25:42 +02:00

438 lines
26 KiB
JavaScript

// ── Context menu ──
import { $, esc, api } from './util.js';
import { state, parseTags, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js';
import { showDialog } from './dialog.js';
import { pushCardEdit, pushCardDelete, pushBatch, pushUndo, snapshotCard } from './undo.js';
import { stripMd } from './markdown.js';
const menu = $('ctx-menu');
const items = $('ctx-items');
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'); }
let ctxCloseTimer = null;
menu.addEventListener('mouseleave', () => {
ctxCloseTimer = setTimeout(hideContextMenu, 400);
});
menu.addEventListener('mouseenter', () => {
if (ctxCloseTimer) { clearTimeout(ctxCloseTimer); ctxCloseTimer = null; }
});
document.addEventListener('click', hideContextMenu);
document.addEventListener('contextmenu', e => { if (!menu.contains(e.target)) hideContextMenu(); });
/**
* Merge every currently-selected card into a single card. Title strategy:
* if the selection involved a bulk gesture (rubber-band) we use the
* longest title — there's no meaningful "first" in that case. Otherwise
* the first ctrl-clicked card wins (state.selectedIds is a Set, which
* preserves insertion order).
*
* Body format: list of all titles as `# heading` lines at the top, then
* each card's body separated by `---`. The winner keeps its color, tags,
* bucket, due_date, parent_id; the others are deleted.
*
* The whole operation is bundled into a single undo entry that recreates
* the deleted cards verbatim and restores the winner's original body.
*/
export async function joinSelectedCards() {
const ids = [...state.selectedIds].map(String);
const cards = ids
.map(id => state.cards.find(c => String(c.id) === id))
.filter(Boolean);
if (cards.length < 2) return;
// We need bodies for the merge. /api/cards GET excludes body for
// performance, so fetch the full rows for the selected ids in one shot.
const fullRes = await fetch(`api/cards?ids=${cards.map(c => c.id).join(',')}`).then(r => r.json());
const fullById = new Map(fullRes.map(c => [String(c.id), c]));
const full = cards.map(c => fullById.get(String(c.id)) || c);
// Pick winner
let winner;
if (state.selectionWasBulk) {
winner = full.reduce((a, b) => (b.title || '').length > (a.title || '').length ? b : a);
} else {
winner = full[0]; // first in Set iteration order = first selected
}
const losers = full.filter(c => c.id !== winner.id);
// Build merged body: title list, then bodies separated by `---`.
// Order: winner first (its body stays at the top after the title list),
// then losers in their selection order so the result is deterministic.
const ordered = [winner, ...losers];
const titleList = ordered.map(c => `## ${c.title || ''}`).join('\n');
const bodyChunks = ordered.map(c => (c.body || '').trim()).filter(Boolean);
const mergedBody = titleList + '\n\n---\n\n' + bodyChunks.join('\n\n---\n\n');
// Snapshot for undo BEFORE we mutate anything
const winnerPrev = { title: winner.title, body: winner.body || '' };
const loserSnapshots = losers.map(c => snapshotCard(c));
// Apply: PATCH winner with the merged body, DELETE losers.
pushUndo(`join ${ordered.length} cards`, async () => {
await api(`api/cards/${winner.id}`, { method: 'PATCH', body: winnerPrev });
for (const snap of loserSnapshots) {
const created = await api('api/cards', { method: 'POST', body: {
title: snap.title, body: snap.body, color: snap.color,
bucket_id: snap.bucket_id, parent_id: snap.parent_id, position: snap.position,
}});
if (created?.id && snap.due_date) {
await api(`api/cards/${created.id}`, { method: 'PATCH', body: { due_date: snap.due_date } });
}
}
});
await api(`api/cards/${winner.id}`, { method: 'PATCH', body: { body: mergedBody } });
for (const c of losers) {
await api(`api/cards/${c.id}`, { method: 'DELETE' });
}
state.selectedIds.clear();
state.selectionWasBulk = false;
await window._reloadAll();
}
export function showContextMenu(x, y, card) {
const children = getChildren(card.id);
const isParent = children.length > 0;
const isChild = !!card.parent_id;
// Multi-select join: only offered when the right-clicked card is part
// of a selection of ≥2 cards. The action merges them into one.
const selected = state.selectedIds;
const inMultiSelect = selected.size >= 2 && selected.has(String(card.id));
items.innerHTML = `
${inMultiSelect ? `<button class="ctx-item" data-action="join"><i class="ph ph-arrows-merge text-sm"></i><span class="font-pixel text-[10px]">Join ${selected.size} cards</span></button><div class="ctx-sep"></div>` : ''}
<button class="ctx-item" data-action="open"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">Open</span></button>
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">Edit</span></button>
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">Duplicate</span></button>
${/^\d{4}-\d{2}-\d{2}/.test(card.title) ? `<button class="ctx-item" data-action="move-date"><i class="ph ph-calendar-dots text-sm"></i><span class="font-pixel text-[10px]">Move to date</span></button>` : ''}
<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>
<button class="ctx-item" data-action="due-done"><i class="ph ph-check-circle text-sm ${parseTags(card).includes('done') ? 'text-t-accent' : ''}"></i><span class="font-pixel text-[10px]">${parseTags(card).includes('done') ? 'Unmark done' : '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="copy-text"><i class="ph ph-clipboard-text text-sm"></i><span class="font-pixel text-[10px]">Copy text</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>
<button class="ctx-item" data-action="bucket"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">Bucket</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
<button class="ctx-item" data-action="tags"><i class="ph ph-hash text-sm"></i><span class="font-pixel text-[10px]">Tags</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
${isParent ? `<div class="ctx-sep"></div>
<button class="ctx-item" data-action="expand-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">Open group</span></button>
<button class="ctx-item" data-action="group-members"><i class="ph ph-list text-sm"></i><span class="font-pixel text-[10px]">Members (${children.length + 1})</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
<button class="ctx-item" data-action="dissolve"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">Dissolve group</span></button>` : ''}
${isChild ? `<div class="ctx-sep"></div>
<button class="ctx-item" data-action="go-to-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">Go to group</span></button>
<button class="ctx-item" data-action="ungroup"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">Ungroup</span></button>` : ''}
<div class="ctx-sep"></div>
<button class="ctx-item danger" data-action="delete"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">Delete</span></button>`;
// Color sub-panel
const grid = $('ctx-color-grid');
grid.innerHTML = CARD_COLORS.map(c => `<button class="w-5 h-5 rounded-sm border-2 transition-all hover:scale-125 ${c.hex === card.color ? 'border-t-bright' : 'border-transparent'}" style="background:${c.hex}" data-ctx-color="${c.hex}"></button>`).join('');
colors.classList.add('hidden');
// Members sub-panel
if (isParent) {
members.innerHTML = [card, ...children].map(gc => `
<button class="ctx-item" data-ctx-member="${gc.id}">
<span class="w-2 h-2 rounded-full shrink-0" style="background:${gc.color}"></span>
<span class="font-pixel text-[10px] truncate flex-1">${esc(gc.title)}</span>
${gc.id !== card.id ? `<i class="ph ph-arrow-line-up text-[10px] text-t-dim hover:text-t-danger" data-ctx-kick="${gc.id}"></i>` : ''}
</button>`).join('');
}
members.classList.add('hidden');
// Bucket sub-panel
buckets.innerHTML = `<button class="ctx-item ${!card.bucket_id ? 'text-t-accent' : ''}" data-ctx-bucket=""><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">No bucket</span></button>` +
state.buckets.map(b => `<button class="ctx-item ${card.bucket_id == b.id ? 'text-t-accent' : ''}" data-ctx-bucket="${b.id}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`).join('');
buckets.classList.add('hidden');
// Tag sub-panel
const cardTags = parseTags(card);
const allTags = [...new Set(state.cards.flatMap(c => parseTags(c)))].sort();
const tagInput = $('ctx-tag-input');
const tagList = $('ctx-tag-list');
function renderCtxTags(filter = '') {
const f = filter.toLowerCase().replace(/^#/, '');
const shown = f ? allTags.filter(t => t.includes(f)) : allTags;
const hasCard = tag => cardTags.includes(tag);
tagList.innerHTML = shown.map(t => `
<button class="ctx-item ${hasCard(t) ? 'text-t-accent' : ''}" data-ctx-tag="${t}">
<i class="ph ${hasCard(t) ? 'ph-check-square' : 'ph-square'} text-sm"></i>
<span class="font-pixel text-[10px]">#${esc(t)}</span>
</button>`).join('') +
(f && !allTags.includes(f) ? `<button class="ctx-item text-t-dim" data-ctx-tag-new="${f}">
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">Add #${esc(f)}</span>
</button>` : '');
tagList.querySelectorAll('[data-ctx-tag]').forEach(btn => btn.addEventListener('click', async e => {
e.stopPropagation();
const tag = btn.dataset.ctxTag;
const has = cardTags.includes(tag);
let newBody = card.body || '';
if (has) {
newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), '');
cardTags.splice(cardTags.indexOf(tag), 1);
} else {
newBody = newBody.trimEnd() + ' #' + tag;
cardTags.push(tag);
}
card.body = newBody;
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
const c = state.cards.find(x => x.id == card.id);
if (c) { c.body = newBody; c.tags = cardTags.join(','); }
renderCtxTags(tagInput.value);
window._renderBoard();
}));
tagList.querySelectorAll('[data-ctx-tag-new]').forEach(btn => btn.addEventListener('click', async e => {
e.stopPropagation();
const tag = btn.dataset.ctxTagNew;
const newBody = (card.body || '').trimEnd() + ' #' + tag;
card.body = newBody;
cardTags.push(tag);
allTags.push(tag);
allTags.sort();
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
const c = state.cards.find(x => x.id == card.id);
if (c) { c.body = newBody; c.tags = cardTags.join(','); }
tagInput.value = '';
renderCtxTags('');
window._renderBoard();
}));
}
renderCtxTags();
tagInput.value = '';
tagInput.addEventListener('input', () => renderCtxTags(tagInput.value));
tagInput.addEventListener('keydown', async e => {
if (e.key !== 'Enter') return;
e.preventDefault();
const raw = tagInput.value.replace(/^#/, '').trim().toLowerCase();
if (!raw) return;
const tag = raw;
if (!cardTags.includes(tag)) {
const newBody = (card.body || '').trimEnd() + ' #' + tag;
card.body = newBody;
cardTags.push(tag);
if (!allTags.includes(tag)) { allTags.push(tag); allTags.sort(); }
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
const c = state.cards.find(x => x.id == card.id);
if (c) { c.body = newBody; c.tags = cardTags.join(','); }
window._renderBoard();
}
tagInput.value = '';
renderCtxTags('');
});
tags.classList.add('hidden');
menu.style.left = '0px';
menu.style.top = '0px';
menu.classList.remove('hidden');
const menuRect = menu.getBoundingClientRect();
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, 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 => {
e.stopPropagation();
const a = btn.dataset.action;
if (a === 'join') {
hideContextMenu();
await joinSelectedCards();
return;
}
if (a === 'open') { hideContextMenu(); window._openDetail(card.id); }
else if (a === 'edit') { hideContextMenu(); window._openModal(card); }
else if (a === 'duplicate') {
hideContextMenu();
const hasDate = /^\d{4}-\d{2}-\d{2}/.test(card.title);
if (hasDate) {
const curDate = card.title.substring(0, 10);
const newDate = await showDialog({ title: 'duplicate_to_date', input: 'date', defaultValue: curDate, okLabel: 'Duplicate' });
if (!newDate) return;
const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
await api('api/cards', { method: 'POST', body: { title: newTitle, body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
} else {
await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
}
await window._reloadAll();
}
else if (a === 'move-date') {
hideContextMenu();
const curDate = card.title.substring(0, 10);
const newDate = await showDialog({ title: 'move_to_date', input: 'date', defaultValue: curDate, okLabel: 'Move' });
if (!newDate || newDate === curDate) return;
const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { title: newTitle } });
await window._reloadAll();
if (card.caldav_uid) window._triggerSync?.();
}
else if (a === 'due-done') {
hideContextMenu();
const isDone = parseTags(card).includes('done');
let newBody;
if (isDone) {
newBody = (card.body || '').replace(/(^|\s)#done\b/g, '$1').replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n');
} else {
newBody = (card.body || '').trimEnd() + ((card.body || '').trimEnd() ? ' ' : '') + '#done';
}
const patch = { body: newBody };
if (!isDone && card.due_date) patch.due_date = null;
await api(`api/cards/${card.id}`, { method: 'PATCH', body: patch });
await window._reloadAll();
}
else if (a === 'due-date') {
hideContextMenu();
const newDate = await showDialog({ title: card.due_date ? 'change due date' : 'set due date', input: 'date', defaultValue: card.due_date || new Date().toISOString().substring(0, 10), okLabel: card.due_date ? 'Update' : 'Set' });
if (newDate === null) return;
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: newDate || null } });
await window._reloadAll();
}
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
else if (a === 'copy-text') {
hideContextMenu();
let body = card.body || '';
// Drop fenced code blocks (incl. slide-image), then markdown structure, then hashtags
body = body.replace(/```[\s\S]*?```/g, '');
body = stripMd(body);
body = body.replace(/(^|\s)#[\w/-]+/g, '$1').replace(/[ \t]+$/gm, '').replace(/\n{2,}/g, '\n\n').trim();
const text = (card.title || '') + '\n\n' + body;
try { await navigator.clipboard.writeText(text); } catch {}
}
else if (a === 'push-relay') {
await openRelayTenantSubmenu(card);
}
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
else if (a === 'color') toggle(colors);
else if (a === 'bucket') toggle(buckets);
else if (a === 'tags') toggle(tags);
else if (a === 'group-members') toggle(members);
else if (a === 'expand-group') { hideContextMenu(); state.activeGroup = card.id; window._renderBoard(); }
else if (a === 'go-to-group') { hideContextMenu(); const p = state.cards.find(c => c.id == card.parent_id); if (p) { state.activeGroup = p.id; window._renderBoard(); } }
else if (a === 'dissolve') {
hideContextMenu(); const ok = await showDialog({ title: 'dissolve_group', message: `Dissolve group "${card.title}"?`, danger: true, okLabel: 'Dissolve' });
if (ok) { const ch = getChildren(card.id); pushBatch('dissolve', ch.map(c => ({ cardId: c.id, prevData: { parent_id: card.id } }))); await Promise.all(ch.map(c => api(`api/cards/${c.id}`, { method: 'PATCH', body: { parent_id: null } }))); await window._reloadAll(); }
}
else if (a === 'ungroup') { hideContextMenu(); pushCardEdit('ungroup', card.id, { parent_id: card.parent_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await window._reloadAll(); }
else if (a === 'delete') {
hideContextMenu();
const needConfirm = localStorage.getItem('hyprcrd-confirm-delete') !== '0';
if (needConfirm) {
const ok = await showDialog({ title: 'delete_card', message: `Really delete "${card.title}"?`, danger: true, okLabel: 'Delete' });
if (!ok) return;
}
const wasSync = !!card.caldav_uid;
pushCardDelete('delete', snapshotCard(card));
await api(`api/cards/${card.id}`, { method: 'DELETE' });
await window._reloadAll();
if (wasSync) window._triggerSync?.();
}
}));
grid.querySelectorAll('[data-ctx-color]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('color', card.id, { color: card.color }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); await window._reloadAll(); }));
buckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); pushCardEdit('bucket', card.id, { bucket_id: card.bucket_id }); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } }); await window._reloadAll(); }));
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 cursor-blink">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);
}