// ── 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'); 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].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') { 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 = `
${msg}
`; document.body.appendChild(n); setTimeout(() => { n.style.transition='opacity .3s'; n.style.opacity='0'; setTimeout(() => n.remove(), 350); }, 3000); } 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(); })); }