// ── 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, snapshotCard } from './undo.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(); }); export function showContextMenu(x, y, card) { const children = getChildren(card.id); const isParent = children.length > 0; const isChild = !!card.parent_id; items.innerHTML = ` ${/^\d{4}-\d{2}-\d{2}/.test(card.title) ? `` : ''} ${card.due_date ? `` : ''}
${isParent ? `
` : ''} ${isChild ? `
` : ''}
`; // Color sub-panel const grid = $('ctx-color-grid'); grid.innerHTML = CARD_COLORS.map(c => ``).join(''); colors.classList.add('hidden'); // Members sub-panel if (isParent) { members.innerHTML = [card, ...children].map(gc => ` `).join(''); } members.classList.add('hidden'); // Bucket sub-panel buckets.innerHTML = `` + state.buckets.map(b => ``).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 => ` `).join('') + (f && !allTags.includes(f) ? `` : ''); 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 === '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(); // Clear due date and mark as done const bodyWithDone = (card.body || '').includes('#done') ? card.body : (card.body || '').trimEnd() + ' #done'; await api(`api/cards/${card.id}`, { method: 'PATCH', body: { due_date: null, body: bodyWithDone } }); 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 === '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 = '
loading tenants...
'; try { const res = await fetch('api/relay/tenants').then(r => r.json()); if (!res.ok) { relayTenants.innerHTML = `
${res.error || 'failed'}
`; return; } cachedRelayTenants = res; } catch (err) { relayTenants.innerHTML = `
${err.message}
`; return; } } renderRelayTenants(card); } function renderRelayTenants(card) { const list = cachedRelayTenants?.tenants || []; const self = cachedRelayTenants?.self || ''; if (!list.length) { relayTenants.innerHTML = '
no tenants
'; 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 ``; }).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 ? '' : '
see browser console for wire-level debug
'; n.innerHTML = `
${esc(msg)}
${hint}`; document.body.appendChild(n); setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, ok ? 3000 : 6000); }