Files
hyprcrd/public/js/modules/context-menu.js
T
thandClaude Opus 4.6 923de0cdd6 hotkeys, gitea ticket-from-card, collab unread, runner auto-retry
board hotkeys (public/js/app.js):
* "c" toggles the calendar from the board (no modifier, body focus,
  no full-screen overlay in front).
* "t" opens a tag quick-pick popover that lists every unique tag from
  state.cards. Filter input + arrow-key/enter pick + ESC/click-outside
  close. Picking sets state.activeTags = [tag] and re-renders the
  board + tag bar. Toggle: a second "t" press closes the picker.
* "1"-"9" picks the Nth user bucket in the bar. The fixed filters
  (all/current/favorites/journal/none) are skipped. Super-buckets
  count as one slot and activate via the new activateBucket() helper
  in buckets.js — that bypasses the click handler so the dropdown
  popover does NOT open; the currently shown member (parent or
  active child) is selected directly.
* ESC chain in the existing handler now also closes #calendar (was
  missing — calendar.js only handles ESC for the day-popup, not the
  whole overlay).

buckets (public/js/modules/buckets.js):
* New exported activateBucket(idOrSpecial) helper: setActiveBucket
  + clear activeGroup + renderBar + window._renderBoard. Used by
  the number-key hotkey to select super-buckets without triggering
  their dropdown.

board split panel (public/js/modules/board.js):
* closeBoardPreview() now removes the .active class from the
  toolbar's #board-split-toggle. The previous code removed
  .text-t-accent which was a no-op since toggleBoardSplit() uses
  .active — the X button on the panel closed the split but left
  the toolbar showing the toggle as still on.

email plugin (public/plugins/email/email.js):
* ESC closes the overlay reliably: the previous handler was on
  #email-overlay, which only fires when focus is *inside* the
  overlay (rare after open). Moved to a document-level keydown.
* Bare "m" toggles the mail overlay open/closed. Suppressed when
  Ctrl/Meta/Alt is held, focus is in an input/textarea/contenteditable,
  or any other full-screen overlay is in front.

collab-buckets (public/plugins/collab-buckets/backend.php):
* When a card change is applied in the pull phase from a foreign
  author (insert / update / tombstone), bump
  collab_buckets.updated_at to NOW for that bucket. has_unread is
  computed as updated_at > last_seen_at and was previously never
  triggered for incoming cards — the bucket-bar tab now gets the
  unread dot + 3-pulse when the user is looking at a different
  bucket. Own pushes are skipped (the relay echoes them back too).

gitea plugin: make ticket from card (public/plugins/gitea/gitea.js):
* New window._giteaMakeIssueFromCard(card) helper. Opens the gitea
  overlay, switches to the create-issue form, fetches the card body
  if it's lazy/null, and pre-fills #gitea-create-title and
  #gitea-create-body. Does NOT submit — the user picks the repo and
  reviews before clicking submit. Soft-bails on the welcome view
  if no gitea account is configured yet.

context-menu (public/js/modules/context-menu.js):
* New "Make ticket" entry in the card right-click menu, only rendered
  when window._giteaMakeIssueFromCard is exposed. Action handler
  hides the menu and calls the helper. Sits next to "Copy text".
* joinSelectedCards() now dedupes hashtag mentions in the merged body
  (first occurrence wins, case-insensitive) so a tag that appeared on
  multiple source cards isn't repeated in the resulting body. Followed
  by a tab/space/blank-line cleanup pass.

test runner auto-retry (public/plugins/tests/runner.js):
* runAll() takes an optional Set<string> of `${suite}::${test}` keys
  and runs only those when set. Progress bar denominator reflects
  the filtered count.
* run() collects every failed test after the first pass, restores the
  backup so the retry runs against the same baseline state, then
  re-runs only those via runAll(failedKeys). Retry results are merged
  into the original results array with `retried: true` and the
  original error preserved on `originalError`.
* renderResults distinguishes three states: passed first try (green ✓),
  pass-on-retry (amber ⚠ "flaky — passed on retry" with the first-run
  error shown), failed twice (red ✗ "failed twice"). Summary line
  carries a "N flaky" count and goes amber if only flaky tests
  appeared in the run.

tests (public/plugins/tests/context-menu.test.js):
* Two new tests for the make-ticket action:
  - action exists in the menu when window._giteaMakeIssueFromCard
    is exposed (soft-skip otherwise)
  - clicking it opens the gitea overlay, surfaces the create-issue
    form, and pre-fills title + body without submitting. Soft-skips
    the assertion when no gitea account is configured (welcome view).

chore: bump version to 0.9.7

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

459 lines
27 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);
let mergedBody = titleList + '\n\n---\n\n' + bodyChunks.join('\n\n---\n\n');
// Dedupe hashtag mentions across the merged body — first occurrence
// wins, subsequent ones get stripped (case-insensitive). Without
// this the merged card has every shared tag listed once per source
// card, which clutters the body and makes the rendered tag chips
// look duplicated to a casual reader (the server's extractTags also
// dedupes the tags column, but the body text itself is what the
// user actually edits).
const seenTags = new Set();
mergedBody = mergedBody.replace(/(^|\s)#([a-zA-Z0-9_/-]+)/g, (match, lead, tag) => {
const key = tag.toLowerCase();
if (seenTags.has(key)) return lead; // strip the dupe, keep the leading whitespace
seenTags.add(key);
return match;
}).replace(/[ \t]{2,}/g, ' ').replace(/\n{3,}/g, '\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>
${typeof window._giteaMakeIssueFromCard === 'function' ? `<button class="ctx-item" data-action="make-ticket"><i class="ph ph-git-pull-request text-sm"></i><span class="font-pixel text-[10px]">Make ticket</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 === 'make-ticket') {
hideContextMenu();
// Gitea plugin pre-fills the create-issue form from this
// card and leaves it open — the user reviews and submits.
await window._giteaMakeIssueFromCard?.(card);
}
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);
}