337 lines
21 KiB
JavaScript
337 lines
21 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, 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 = `
|
|
<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>
|
|
${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><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 === '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: `Gruppe "${card.title}" aufloesen?`, 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: `"${card.title}" wirklich loeschen?`, 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">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);
|
|
}
|