/* ══════════════════════════════════════ hyprcrd — app.js ══════════════════════════════════════ */ // ── Utilities ── function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } function escRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } async function api(path, opts = {}) { const res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...opts, body: opts.body ? JSON.stringify(opts.body) : undefined, }); return res.json(); } // ── Custom dialogs ── const dialogOverlay = document.getElementById('dialog'); const dialogTitle = document.getElementById('dialog-title'); const dialogMsg = document.getElementById('dialog-msg'); const dialogInput = document.getElementById('dialog-input'); const dialogCancel = document.getElementById('dialog-cancel'); const dialogOk = document.getElementById('dialog-ok'); const dialogClose = document.getElementById('dialog-close'); let dialogResolve = null; function showDialog({ title, message = '', input = false, placeholder = '', danger = false, okLabel = 'ok', cancelLabel = 'cancel' }) { return new Promise(resolve => { dialogResolve = resolve; dialogTitle.textContent = title; dialogMsg.textContent = message; dialogMsg.classList.toggle('hidden', !message); dialogInput.classList.toggle('hidden', input === false); dialogInput.value = ''; dialogInput.placeholder = placeholder; dialogOk.textContent = okLabel; dialogOk.classList.toggle('border-t-danger', danger); dialogOk.classList.toggle('text-t-danger', danger); dialogOk.classList.toggle('border-t-accent', !danger); dialogOk.classList.toggle('text-t-accent', !danger); dialogCancel.textContent = cancelLabel; dialogCancel.classList.toggle('hidden', !cancelLabel); dialogClose.classList.toggle('hidden', !cancelLabel); dialogInput.type = input === 'password' ? 'password' : 'text'; dialogOverlay.classList.add('open'); if (input) dialogInput.focus(); else dialogOk.focus(); }); } function closeDialog(result) { dialogOverlay.classList.remove('open'); if (dialogResolve) { dialogResolve(result); dialogResolve = null; } } dialogOk.addEventListener('click', () => { const val = dialogInput.classList.contains('hidden') ? true : dialogInput.value; closeDialog(val); }); dialogCancel.addEventListener('click', () => { if (!dialogCancel.classList.contains('hidden')) closeDialog(null); }); dialogClose.addEventListener('click', () => { if (!dialogClose.classList.contains('hidden')) closeDialog(null); }); dialogOverlay.addEventListener('click', e => { if (e.target === dialogOverlay && !dialogCancel.classList.contains('hidden')) closeDialog(null); }); dialogOverlay.addEventListener('keydown', e => { if (e.key === 'Escape' && !dialogCancel.classList.contains('hidden')) closeDialog(null); if (e.key === 'Enter') { const val = dialogInput.classList.contains('hidden') ? true : dialogInput.value; closeDialog(val); } }); // ── Markdown ── marked.use({ breaks: true, gfm: true }); let currentCards = []; function renderMd(src) { const linked = (src || '').replace(/\[\[([^\]]+)\]\]/g, (_, title) => { const card = currentCards.find(c => c.title.toLowerCase() === title.toLowerCase()); if (card) return `${esc(title)}`; return `${esc(title)}`; }); return marked.parse(linked); } function getTodos(src) { const s = src || ''; const total = (s.match(/- \[([ x])\]/g) || []).length; if (!total) return null; const done = (s.match(/- \[x\]/g) || []).length; return { total, done }; } function firstImage(src) { const s = src || ''; const md = s.match(/!\[[^\]]*\]\(([^)]+)\)/); if (md) return md[1]; const html = s.match(/]+src="([^"]+)"/i); return html ? html[1] : null; } function leadingImage(src) { const s = (src || '').trimStart(); if (!s) return null; const md = s.match(/^!\[[^\]]*\]\(([^)]+)\)/); if (md) { const raw = md[1].trim(); const withoutTitle = raw.split(/\s+/)[0]; const url = withoutTitle.replace(/^<(.+)>$/, '$1'); return url || null; } const html = s.match(/^]+src=(['"])(.*?)\1/i); return html ? html[2] : null; } function stripMd(src) { return (src || '') .replace(/]*>/gi, '') .replace(/<[^>]+>/g, '') .replace(/!\[[^\]]*\]\([^)]*\)/g, '') .replace(/\[\[([^\]]+)\]\]/g, '$1') .replace(/^#{1,6}\s+/gm, '') .replace(/(\*{1,3}|_{1,3}|~~)(.*?)\1/g, '$2') .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/`{1,3}[^`]*`{1,3}/g, '') .replace(/^[-*>]\s+/gm, '') .replace(/^\d+\.\s+/gm, '') .replace(/\n{2,}/g, '\n') .trim(); } // ── Theme picker ── const picker = document.getElementById('theme-picker'); const themeList = document.getElementById('theme-list'); const themeToggle = document.getElementById('theme-toggle'); if (themeList) { for (const [key, t] of Object.entries(themes)) { const btn = document.createElement('button'); btn.className = 'flex items-center gap-2.5 w-full px-2 py-1.5 text-left text-xs text-t-muted hover:text-t-bright transition-colors rounded'; btn.dataset.themeDot = key; btn.innerHTML = ` ${t.label}`; btn.onclick = () => applyTheme(key); themeList.appendChild(btn); } } applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate'); if (picker && themeToggle) { themeToggle.addEventListener('click', () => picker.classList.toggle('open')); document.addEventListener('click', e => { if (!picker.contains(e.target)) picker.classList.remove('open'); }); } // ── Color presets ── const cardColors = [ { name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' }, { name: 'rose', hex: '#fb7185' }, { name: 'amber', hex: '#fbbf24' }, { name: 'green', hex: '#4ade80' }, { name: 'orange', hex: '#fb923c' }, { name: 'cyan', hex: '#22d3ee' }, { name: 'pink', hex: '#f472b6' }, { name: 'lime', hex: '#a3e635' }, { name: 'slate', hex: '#94a3b8' }, ]; const presetContainer = document.getElementById('color-presets'); const inpColor = document.getElementById('inp-color'); cardColors.forEach(c => { const btn = document.createElement('button'); btn.type = 'button'; btn.title = c.name; btn.dataset.color = c.hex; btn.className = 'w-5 h-5 rounded-sm border-2 border-transparent transition-all hover:scale-125 color-swatch'; btn.style.background = c.hex; btn.addEventListener('click', () => selectColor(c.hex)); presetContainer.appendChild(btn); }); function selectColor(hex) { inpColor.value = hex; presetContainer.querySelectorAll('.color-swatch').forEach(el => { const active = el.dataset.color === hex; el.classList.toggle('border-t-bright', active); el.classList.toggle('border-transparent', !active); el.classList.toggle('scale-125', active); }); } // ── Buckets ── const bucketBar = document.getElementById('bucket-bar'); const inpBucket = document.getElementById('inp-bucket'); const searchBucketFilter = document.getElementById('search-bucket-filter'); const tasksBucketFilter = document.getElementById('tasks-bucket-filter'); let currentBuckets = []; let activeBucket = 'all'; async function loadBuckets() { currentBuckets = await api('api/buckets'); renderBucketBar(); populateBucketSelects(); } function renderBucketBar() { const tabClass = (active) => `bucket-tab ${active ? 'active' : ''} flex items-center gap-1.5 px-2.5 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent hover:text-t-accent transition-colors shrink-0`; bucketBar.innerHTML = ` ${currentBuckets.map(b => ` `).join('')} `; bucketBar.querySelectorAll('.bucket-tab').forEach(tab => { tab.addEventListener('click', () => { activeBucket = tab.dataset.bucket; renderBucketBar(); renderBoard(); }); tab.addEventListener('dragover', e => { e.preventDefault(); tab.classList.add('drag-over'); }); tab.addEventListener('dragleave', () => tab.classList.remove('drag-over')); tab.addEventListener('drop', async e => { e.preventDefault(); tab.classList.remove('drag-over'); const cardId = e.dataTransfer.getData('text/plain'); if (!cardId) return; const bucketId = tab.dataset.bucket === 'none' ? null : (tab.dataset.bucket === 'all' ? undefined : tab.dataset.bucket); if (bucketId === undefined) return; await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: bucketId } }); await reloadAll(); }); }); document.getElementById('add-bucket-btn').addEventListener('click', async () => { const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' }); if (!name || !name.trim()) return; await api('api/buckets', { method: 'POST', body: { name: name.trim() } }); await loadBuckets(); renderBoard(); }); } function populateBucketSelects() { inpBucket.innerHTML = `` + currentBuckets.map(b => ``).join(''); searchBucketFilter.innerHTML = `` + currentBuckets.map(b => ``).join(''); if (tasksBucketFilter) { const current = tasksBucketFilter.value; tasksBucketFilter.innerHTML = `` + currentBuckets.map(b => ``).join(''); const stillExists = current === '' || current === 'none' || currentBuckets.some(b => String(b.id) === current); tasksBucketFilter.value = stillExists ? current : ''; } } // ── Board ── const board = document.getElementById('board'); let activeGroup = null; // card id of group parent, or null let sortMode = localStorage.getItem('hyprcrd-sort') || 'manual'; // 'manual', 'newest', 'oldest', 'alpha' const sortModes = ['manual', 'newest', 'oldest', 'alpha']; const sortIcons = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa' }; function updateSortUI() { document.getElementById('sort-label').textContent = sortMode; const icon = document.querySelector('#sort-toggle i'); icon.className = `ph ${sortIcons[sortMode]} text-xs`; } document.getElementById('sort-toggle').addEventListener('click', () => { sortMode = sortModes[(sortModes.indexOf(sortMode) + 1) % sortModes.length]; localStorage.setItem('hyprcrd-sort', sortMode); updateSortUI(); renderBoard(); }); async function loadCards() { currentCards = await api('api/cards'); updateSortUI(); renderBoard(); } async function reloadAll() { currentCards = await api('api/cards'); renderBoard(); } function sortedCards(cards) { const arr = [...cards]; switch (sortMode) { case 'newest': return arr.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')); case 'oldest': return arr.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || '')); case 'alpha': return arr.sort((a, b) => (a.title || '').localeCompare(b.title || '')); default: return arr.sort((a, b) => (a.position || 0) - (b.position || 0)); } } function getChildren(parentId) { return currentCards.filter(c => c.parent_id == parentId); } function isGroupParent(id) { return currentCards.some(c => c.parent_id == id); } function renderBoard() { let cards; if (activeGroup) { const parent = currentCards.find(c => c.id == activeGroup); cards = parent ? [parent, ...getChildren(parent.id)] : []; } else { cards = currentCards.filter(c => !c.parent_id); if (activeBucket === 'none') cards = cards.filter(c => !c.bucket_id); else if (activeBucket !== 'all') cards = cards.filter(c => c.bucket_id == activeBucket); } cards = sortedCards(cards); refreshTasksView(); // Group breadcrumb bar const groupBar = document.getElementById('group-bar'); if (activeGroup) { const parent = currentCards.find(c => c.id == activeGroup); groupBar.classList.remove('hidden'); groupBar.innerHTML = ` ${esc(parent?.title || 'group')} ${cards.length} cards`; groupBar.querySelector('#group-back').addEventListener('click', () => { activeGroup = null; renderBoard(); }); } else { groupBar.classList.add('hidden'); } if (!cards.length) { board.innerHTML = `
`; return; } const recent = getRecent(); const inGroup = !!activeGroup; board.innerHTML = cards.map(c => { const img = firstImage(c.body); const coverImg = leadingImage(c.body); const hasCover = !!coverImg; const thumbImg = hasCover ? null : img; const children = getChildren(c.id); const isGroup = !inGroup && children.length > 0; const todos = getTodos(c.body); const todoPct = todos ? Math.round(todos.done / todos.total * 100) : 0; return `
${hasCover ? '
' : ''}
${isGroup ? `
${children.length + 1}
` : ''} ${thumbImg ? `
` : ''}
${todos ? `` : ''}

${esc(c.title)}

${todos ? `
${todos.done}/${todos.total}
` : ''}

${esc(stripMd(c.body))}

${c.bucket_name ? `
${esc(c.bucket_name)}
` : ''}
`; }).join(''); // Group badge clicks board.querySelectorAll('[data-group]').forEach(badge => { badge.addEventListener('click', e => { e.stopPropagation(); activeGroup = badge.dataset.group; renderBoard(); }); }); // Events board.querySelectorAll('[data-id]').forEach(el => { const id = el.dataset.id; // Click = detail (delayed), dblclick = edit let clickTimer = null; el.addEventListener('click', () => { if (clickTimer) return; clickTimer = setTimeout(() => { clickTimer = null; openDetail(id); }, 200); }); el.addEventListener('dblclick', e => { e.preventDefault(); if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; } const card = currentCards.find(c => c.id == id); if (card) openModal(card); }); // Right-click context menu el.addEventListener('contextmenu', e => { e.preventDefault(); e.stopPropagation(); const card = currentCards.find(c => c.id == id); if (card) showContextMenu(e.clientX, e.clientY, card); }); el.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', id); e.dataTransfer.effectAllowed = 'move'; el.classList.add('card-dragging'); }); el.addEventListener('dragend', () => { el.classList.remove('card-dragging'); board.querySelectorAll('.card-drop-before,.card-drop-after,.card-drop-group').forEach(x => x.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group')); }); el.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = el.getBoundingClientRect(); const edgeZone = rect.width * 0.25; const x = e.clientX - rect.left; const isLeft = x < edgeZone; const isRight = x > rect.width - edgeZone; const isCenter = !isLeft && !isRight; el.classList.toggle('card-drop-before', isLeft); el.classList.toggle('card-drop-after', isRight); el.classList.toggle('card-drop-group', isCenter); }); el.addEventListener('dragleave', () => { el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'); }); el.addEventListener('drop', async e => { e.preventDefault(); const wasGroup = el.classList.contains('card-drop-group'); const wasBefore = el.classList.contains('card-drop-before'); el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'); const dragId = e.dataTransfer.getData('text/plain'); if (!dragId || dragId === id) return; if (wasGroup) { // Group: set dragged card's parent_id to target await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: id } }); await reloadAll(); return; } // Reorder let filtered; if (activeGroup) { const parent = currentCards.find(c => c.id == activeGroup); filtered = parent ? sortedCards([parent, ...getChildren(parent.id)]) : []; } else { filtered = sortedCards(currentCards.filter(c => { if (c.parent_id) return false; if (activeBucket === 'none') return !c.bucket_id; if (activeBucket !== 'all') return c.bucket_id == activeBucket; return true; })); } const ids = filtered.map(c => String(c.id)).filter(i => i !== String(dragId)); const targetIdx = ids.indexOf(String(id)); const insertIdx = wasBefore ? targetIdx : targetIdx + 1; ids.splice(insertIdx, 0, String(dragId)); // Ungroup dragged card if it was a child (when dropping on main board) const dragCard = currentCards.find(c => c.id == dragId); if (!activeGroup && dragCard && dragCard.parent_id) { await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: null } }); } // FLIP: capture old positions const oldRects = {}; board.querySelectorAll('[data-id]').forEach(card => { oldRects[card.dataset.id] = card.getBoundingClientRect(); }); const updates = ids.map((cid, i) => api(`api/cards/${cid}`, { method: 'PATCH', body: { position: i } })); await Promise.all(updates); // If in group view and first card changed, swap parent if (activeGroup && ids[0] !== String(activeGroup)) { const newParentId = ids[0]; const oldParentId = String(activeGroup); // New first card becomes the parent: clear its parent_id await api(`api/cards/${newParentId}`, { method: 'PATCH', body: { parent_id: null } }); // Old parent becomes a child of the new parent await api(`api/cards/${oldParentId}`, { method: 'PATCH', body: { parent_id: newParentId } }); // All other children point to new parent for (const cid of ids.slice(1)) { if (cid !== oldParentId) { await api(`api/cards/${cid}`, { method: 'PATCH', body: { parent_id: newParentId } }); } } activeGroup = newParentId; } sortMode = 'manual'; localStorage.setItem('hyprcrd-sort', 'manual'); updateSortUI(); await reloadAll(); // FLIP: animate board.querySelectorAll('[data-id]').forEach(card => { const oldRect = oldRects[card.dataset.id]; if (!oldRect) return; const newRect = card.getBoundingClientRect(); const dx = oldRect.left - newRect.left; const dy = oldRect.top - newRect.top; if (dx === 0 && dy === 0) return; card.style.transition = 'none'; card.style.transform = `translate(${dx}px, ${dy}px)`; requestAnimationFrame(() => { card.style.transition = 'transform 0.25s ease'; card.style.transform = ''; }); }); }); }); } // ── Detail view ── const detail = document.getElementById('detail'); const detailScroll = document.getElementById('detail-scroll'); const detailTitle = document.getElementById('detail-title'); const detailBody = document.getElementById('detail-body'); const detailMeta = document.getElementById('detail-meta'); let viewingCard = null; let openedFromSearch = false; const DETAIL_ZOOM_KEY = 'hyprcrd-detail-zoom'; const DETAIL_ZOOM_STEP = 0.08; const DETAIL_ZOOM_MIN = 0.75; const DETAIL_ZOOM_MAX = 2.2; let detailZoom = Number.parseFloat(localStorage.getItem(DETAIL_ZOOM_KEY) || '1'); if (!Number.isFinite(detailZoom)) detailZoom = 1; function setDetailZoom(nextZoom) { detailZoom = Math.min(DETAIL_ZOOM_MAX, Math.max(DETAIL_ZOOM_MIN, nextZoom)); detailZoom = Math.round(detailZoom * 100) / 100; localStorage.setItem(DETAIL_ZOOM_KEY, String(detailZoom)); applyDetailZoom(); } function applyDetailZoom() { if (!detailTitle || !detailBody || !detailMeta) return; detailTitle.style.fontSize = `${(1.125 * detailZoom).toFixed(3)}rem`; detailBody.style.fontSize = `${(0.875 * detailZoom).toFixed(3)}rem`; detailMeta.style.fontSize = `${Math.max(0.625, 0.6875 * detailZoom).toFixed(3)}rem`; detailTitle.style.lineHeight = `${Math.max(1.35, 1.55 + (detailZoom - 1) * 0.3).toFixed(3)}`; detailBody.style.lineHeight = `${Math.max(1.5, 1.7 + (detailZoom - 1) * 0.35).toFixed(3)}`; detailMeta.style.lineHeight = `${Math.max(1.2, 1.35 + (detailZoom - 1) * 0.25).toFixed(3)}`; } applyDetailZoom(); // Recently opened tracking function getRecent() { try { return JSON.parse(localStorage.getItem('hyprcrd-recent') || '[]'); } catch { return []; } } function markRecent(id) { const r = getRecent().filter(i => i != id); r.unshift(String(id)); localStorage.setItem('hyprcrd-recent', JSON.stringify(r.slice(0, 10))); } function isRecent(id) { return getRecent().includes(String(id)); } // Resolve the group context: if card is a child, find parent + siblings function getGroupCards(card) { if (card.parent_id) { const parent = currentCards.find(c => c.id == card.parent_id); if (parent) return [parent, ...getChildren(parent.id)]; } const children = getChildren(card.id); if (children.length) return [card, ...children]; return null; // not a group } function openDetail(id) { const card = currentCards.find(c => c.id == id); if (!card) return; markRecent(id); const el = board.querySelector(`[data-id="${id}"]`); if (el) el.style.borderBottom = `3px solid ${card.color}`; if (!detail.classList.contains('open')) { openedFromSearch = searchOverlay.classList.contains('open'); } viewingCard = card; // Group sidebar const sidebar = document.getElementById('detail-sidebar'); const sidebarList = document.getElementById('detail-sidebar-list'); const groupCards = getGroupCards(card); if (groupCards && groupCards.length > 1) { sidebar.classList.remove('hidden'); document.getElementById('detail-sidebar-count').textContent = groupCards.length; sidebarList.innerHTML = groupCards.map(gc => { const active = gc.id == card.id; const img = firstImage(gc.body); return ` `; }).join(''); sidebarList.querySelectorAll('.sidebar-card').forEach(sc => { sc.addEventListener('click', () => showDetailCard(sc.dataset.sid)); }); } else { sidebar.classList.add('hidden'); } showDetailContent(card); detail.classList.add('open'); } function showDetailCard(id) { const card = currentCards.find(c => c.id == id); if (!card) return; viewingCard = card; markRecent(id); // Update sidebar active state document.querySelectorAll('#detail-sidebar-list .sidebar-card').forEach(sc => { const active = sc.dataset.sid == id; sc.classList.toggle('border-t-accent', active); sc.classList.toggle('bg-t-raised', active); sc.classList.toggle('border-transparent', !active); sc.querySelector('span:last-of-type')?.classList.toggle('text-t-bright', active); sc.querySelector('span:last-of-type')?.classList.toggle('text-t-muted', !active); }); showDetailContent(card); } function showDetailContent(card) { document.getElementById('detail-color').style.background = card.color; detailTitle.textContent = card.title; detailBody.innerHTML = renderMd(card.body); // Make checkboxes interactive detailBody.querySelectorAll('input[type="checkbox"]').forEach((cb, idx) => { cb.disabled = false; cb.addEventListener('change', async () => { // Toggle the nth checkbox in markdown source let body = card.body; let n = 0; body = body.replace(/- \[([ x])\]/g, (match, state) => { if (n++ === idx) return `- [${state === 'x' ? ' ' : 'x'}]`; return match; }); card.body = body; await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } }); // Update card in local state const c = currentCards.find(x => x.id == card.id); if (c) c.body = body; renderBoard(); }); }); const meta = '> created: ' + card.created_at + ' | modified: ' + card.updated_at + (card.bucket_name ? ' | bucket: ' + card.bucket_name : ''); detailMeta.textContent = meta; applyDetailZoom(); // Backlinks const pattern = `[[${card.title}]]`.toLowerCase(); const backlinks = currentCards.filter(c => c.id != card.id && (c.body || '').toLowerCase().includes(pattern)); const blContainer = document.getElementById('detail-backlinks'); const blList = document.getElementById('detail-backlinks-list'); if (backlinks.length) { blContainer.classList.remove('hidden'); blList.innerHTML = backlinks.map(b => ` `).join(''); } else { blContainer.classList.add('hidden'); } } function closeDetail() { detail.classList.remove('open'); viewingCard = null; if (openedFromSearch) { openedFromSearch = false; searchInput.focus(); } } detail.addEventListener('click', e => { if (e.target === detail) closeDetail(); }); if (detailScroll) { detailScroll.addEventListener('wheel', e => { if (!detail.classList.contains('open')) return; if (!e.shiftKey) return; // zoom only with Shift+wheel if (Math.abs(e.deltaY) < Math.abs(e.deltaX)) return; e.preventDefault(); const step = e.deltaY < 0 ? DETAIL_ZOOM_STEP : -DETAIL_ZOOM_STEP; setDetailZoom(detailZoom + step); }, { passive: false }); } async function ungroupCard(id) { await api(`api/cards/${id}`, { method: 'PATCH', body: { parent_id: null } }); await reloadAll(); if (viewingCard) { // If we ungrouped the card we're viewing, or the parent, re-open if (viewingCard.id == id) { openDetail(id); } else { openDetail(viewingCard.id); } } } document.getElementById('detail-edit').addEventListener('click', () => { if (!viewingCard) return; const card = viewingCard; openedFromSearch = false; closeDetail(); closeSearch(); openModal(card); }); document.getElementById('detail-delete').addEventListener('click', async () => { if (!viewingCard) return; const ok = await showDialog({ title: 'delete_card', message: `"${viewingCard.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' }); if (!ok) return; await api(`api/cards/${viewingCard.id}`, { method: 'DELETE' }); closeDetail(); await reloadAll(); }); document.getElementById('detail-close').addEventListener('click', closeDetail); // ── Editor ── const modal = document.getElementById('modal'); const editorBody = document.getElementById('editor-body'); const previewPane = document.getElementById('editor-preview'); const previewContent = document.getElementById('preview-content'); let editingId = null; let previewVisible = false; function openModal(card = null) { editingId = card ? card.id : null; document.getElementById('modal-title').textContent = card ? 'edit_card' : 'new_card'; document.getElementById('inp-title').value = card ? card.title : ''; editorBody.value = card ? card.body : ''; inpBucket.value = card && card.bucket_id ? card.bucket_id : (activeBucket !== 'all' && activeBucket !== 'none' ? activeBucket : ''); selectColor(card ? card.color : '#38bdf8'); previewVisible = true; previewPane.classList.remove('hidden'); document.getElementById('toggle-preview').classList.add('text-t-accent'); updatePreview(); modal.classList.add('open'); document.getElementById('inp-title').focus(); } function closeModal() { modal.classList.remove('open'); editingId = null; } function updatePreview() { if (previewVisible) previewContent.innerHTML = renderMd(editorBody.value); } async function saveCard() { const data = { title: document.getElementById('inp-title').value, body: editorBody.value, color: inpColor.value, bucket_id: inpBucket.value || null, }; if (!data.title.trim()) return; if (editingId) { await api(`api/cards/${editingId}`, { method: 'PATCH', body: data }); } else { // New cards get position -1 so they sort before everything at position 0+ data.position = -1; await api('api/cards', { method: 'POST', body: data }); } closeModal(); await reloadAll(); } function insertMd(before, after = '', placeholder = '') { editorBody.focus(); const start = editorBody.selectionStart, end = editorBody.selectionEnd; const sel = editorBody.value.substring(start, end) || placeholder; editorBody.setRangeText(before + sel + after, start, end, 'select'); editorBody.selectionStart = start + before.length; editorBody.selectionEnd = start + before.length + sel.length; updatePreview(); } document.getElementById('md-toolbar').addEventListener('click', e => { const btn = e.target.closest('[data-md]'); if (!btn) return; const actions = { heading: () => insertMd('## ', '', 'Heading'), bold: () => insertMd('**', '**', 'bold'), italic: () => insertMd('*', '*', 'italic'), strikethrough: () => insertMd('~~', '~~', 'text'), code: () => insertMd('`', '`', 'code'), codeblock: () => insertMd('```\n', '\n```', 'code'), ul: () => insertMd('- ', '', 'item'), ol: () => insertMd('1. ', '', 'item'), quote: () => insertMd('> ', '', 'quote'), todo: () => insertMd('- [ ] ', '', 'task'), link: () => insertMd('[', '](url)', 'text'), cardlink: () => { insertMd('[[', ']]', ''); showLinkAc(''); }, hr: () => insertMd('\n---\n'), }; if (actions[btn.dataset.md]) actions[btn.dataset.md](); }); document.getElementById('toggle-preview').addEventListener('click', () => { previewVisible = !previewVisible; previewPane.classList.toggle('hidden', !previewVisible); document.getElementById('toggle-preview').classList.toggle('text-t-accent', previewVisible); updatePreview(); }); document.getElementById('add-btn').addEventListener('click', () => openModal()); document.getElementById('modal-cancel').addEventListener('click', closeModal); document.getElementById('modal-save').addEventListener('click', saveCard); // ── Card link autocomplete ── const linkAc = document.getElementById('link-ac'); let linkAcVisible = false, linkAcItems = [], linkAcActive = 0; function getLinkQuery() { const before = editorBody.value.substring(0, editorBody.selectionStart); const idx = before.lastIndexOf('[['); if (idx === -1) return null; const between = before.substring(idx + 2); if (between.includes(']]')) return null; return { start: idx, query: between }; } function showLinkAc(query) { const q = query.toLowerCase(); linkAcItems = currentCards .filter(c => c.id != editingId) .map(c => { const t = c.title.toLowerCase(); let score = 0; if (t.startsWith(q)) score = 100; else if (t.includes(q)) score = 50; else if (fuzzyMatch(q, t) !== null) score = 10; else if (!q) score = 1; else return null; return { card: c, score }; }) .filter(Boolean).sort((a, b) => b.score - a.score).slice(0, 8).map(s => s.card); if (!linkAcItems.length) { hideLinkAc(); return; } linkAcActive = 0; linkAc.innerHTML = linkAcItems.map((c, i) => `
${esc(c.title)} ${c.bucket_name ? `${esc(c.bucket_name)}` : ''}
`).join(''); linkAc.querySelectorAll('.ac-item').forEach(el => { el.addEventListener('mousedown', e => { e.preventDefault(); completeLinkAc(parseInt(el.dataset.idx)); }); }); // Position const rect = editorBody.getBoundingClientRect(); const lines = editorBody.value.substring(0, editorBody.selectionStart).split('\n'); let top = rect.top + (lines.length - 1) * 22 - editorBody.scrollTop + 26; let left = rect.left + 40; top = Math.min(top, window.innerHeight - 200); left = Math.min(left, window.innerWidth - 380); linkAc.style.top = top + 'px'; linkAc.style.left = left + 'px'; linkAc.classList.remove('hidden'); linkAcVisible = true; } function hideLinkAc() { linkAc.classList.add('hidden'); linkAcVisible = false; } function completeLinkAc(idx) { const card = linkAcItems[idx]; if (!card) return; const info = getLinkQuery(); if (!info) { hideLinkAc(); return; } const before = editorBody.value.substring(0, info.start); let after = editorBody.value.substring(editorBody.selectionStart); if (after.startsWith(']]')) after = after.substring(2); else if (after.startsWith(']')) after = after.substring(1); const insert = `[[${card.title}]]`; editorBody.value = before + insert + after; editorBody.selectionStart = editorBody.selectionEnd = info.start + insert.length; hideLinkAc(); updatePreview(); editorBody.focus(); } editorBody.addEventListener('input', () => { const info = getLinkQuery(); if (info) showLinkAc(info.query); else hideLinkAc(); updatePreview(); }); editorBody.addEventListener('keydown', e => { if (linkAcVisible) { if (e.key === 'ArrowDown') { e.preventDefault(); linkAcActive = Math.min(linkAcActive + 1, linkAcItems.length - 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); linkAcActive = Math.max(linkAcActive - 1, 0); } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); completeLinkAc(linkAcActive); return; } else if (e.key === 'Escape') { e.preventDefault(); hideLinkAc(); return; } linkAc.querySelectorAll('.ac-item').forEach((el, i) => el.classList.toggle('active', i === linkAcActive)); return; } if (e.key === 'Tab') { e.preventDefault(); insertMd(' '); } }); editorBody.addEventListener('blur', () => setTimeout(hideLinkAc, 150)); // ── Context menu ── const ctxMenu = document.getElementById('ctx-menu'); const ctxItems = document.getElementById('ctx-items'); const ctxColors = document.getElementById('ctx-colors'); const ctxBuckets = document.getElementById('ctx-buckets'); let ctxCard = null; function hideContextMenu() { ctxMenu.classList.add('hidden'); ctxCard = null; } document.addEventListener('click', () => hideContextMenu()); document.addEventListener('contextmenu', e => { if (!ctxMenu.contains(e.target)) hideContextMenu(); }); function showContextMenu(x, y, card) { ctxCard = card; // Group context const children = getChildren(card.id); const isGroupParent = children.length > 0; const isChild = !!card.parent_id; // Main items ctxItems.innerHTML = `
${isGroupParent ? `
` : ''} ${isChild ? `
` : ''}
`; // Color sub-panel const ctxColorGrid = document.getElementById('ctx-color-grid'); ctxColorGrid.innerHTML = cardColors.map(c => `` ).join(''); ctxColors.classList.add('hidden'); // Members sub-panel const ctxMembers = document.getElementById('ctx-members'); if (isGroupParent) { const allGroup = [card, ...children]; ctxMembers.innerHTML = allGroup.map(gc => ` `).join(''); } ctxMembers.classList.add('hidden'); // Bucket sub-panel ctxBuckets.innerHTML = ` ${currentBuckets.map(b => ` `).join('')}`; ctxBuckets.classList.add('hidden'); // Position const mw = 180; const mh = 300; ctxMenu.style.left = Math.min(x, window.innerWidth - mw - 8) + 'px'; ctxMenu.style.top = Math.min(y, window.innerHeight - mh - 8) + 'px'; ctxMenu.classList.remove('hidden'); // Action handlers ctxItems.querySelectorAll('[data-action]').forEach(btn => { btn.addEventListener('click', async e => { e.stopPropagation(); const action = btn.dataset.action; if (action === 'open') { hideContextMenu(); openDetail(card.id); } else if (action === 'edit') { hideContextMenu(); openModal(card); } else if (action === 'duplicate') { hideContextMenu(); 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 reloadAll(); } else if (action === 'color') { ctxColors.classList.toggle('hidden'); ctxBuckets.classList.add('hidden'); ctxMembers.classList.add('hidden'); } else if (action === 'bucket') { ctxBuckets.classList.toggle('hidden'); ctxColors.classList.add('hidden'); ctxMembers.classList.add('hidden'); } else if (action === 'group-members') { ctxMembers.classList.toggle('hidden'); ctxColors.classList.add('hidden'); ctxBuckets.classList.add('hidden'); } else if (action === 'expand-group') { hideContextMenu(); activeGroup = card.id; renderBoard(); } else if (action === 'go-to-group') { hideContextMenu(); const parent = currentCards.find(c => c.id == card.parent_id); if (parent) { activeGroup = parent.id; renderBoard(); } } else if (action === 'dissolve') { hideContextMenu(); const ok = await showDialog({ title: 'dissolve_group', message: `Gruppe "${card.title}" aufloesen? Alle Karten werden einzeln.`, danger: true, okLabel: 'dissolve' }); if (!ok) return; const ch = getChildren(card.id); await Promise.all(ch.map(c => api(`api/cards/${c.id}`, { method: 'PATCH', body: { parent_id: null } }))); await reloadAll(); } else if (action === 'ungroup') { hideContextMenu(); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } }); await reloadAll(); } else if (action === 'delete') { hideContextMenu(); const ok = await showDialog({ title: 'delete_card', message: `"${card.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' }); if (!ok) return; await api(`api/cards/${card.id}`, { method: 'DELETE' }); await reloadAll(); } }); }); // Color clicks ctxColorGrid.querySelectorAll('[data-ctx-color]').forEach(btn => { btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } }); await reloadAll(); }); }); // Bucket clicks ctxBuckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => { btn.addEventListener('click', async e => { e.stopPropagation(); hideContextMenu(); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } }); await reloadAll(); }); }); // Member clicks ctxMembers.querySelectorAll('[data-ctx-member]').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); if (e.target.closest('[data-ctx-kick]')) return; // handled below hideContextMenu(); openDetail(btn.dataset.ctxMember); }); }); ctxMembers.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 reloadAll(); }); }); } // ── Image resize in preview ── document.addEventListener('mousedown', e => { const img = e.target.closest('.prose img'); if (!img) return; e.preventDefault(); img.classList.add('resizing'); const startX = e.clientX; const startW = img.offsetWidth; function onMove(ev) { const delta = ev.clientX - startX; const newW = Math.max(50, startW + delta); img.style.width = newW + 'px'; img.style.maxWidth = 'none'; } function onUp() { img.classList.remove('resizing'); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); // Persist width back into editor markdown if in editor preview const width = Math.round(img.offsetWidth); const src = img.getAttribute('src'); if (src && modal.classList.contains('open')) { const val = editorBody.value; // Match ![...](src) or and set width via HTML img tag const mdPattern = new RegExp('!\\[([^\\]]*)\\]\\(' + escRe(src) + '\\)'); const match = val.match(mdPattern); if (match) { const replacement = `${match[1]}`; editorBody.value = val.replace(match[0], replacement); updatePreview(); } else { // Already an tag — update width const imgTagRe = new RegExp('(]*src="' + escRe(src) + '"[^>]*?)(?:\\swidth="[^"]*")?([^>]*>)'); const imgMatch = val.match(imgTagRe); if (imgMatch) { editorBody.value = val.replace(imgMatch[0], imgMatch[1] + ` width="${width}"` + imgMatch[2]); updatePreview(); } } } } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); // ── Clipboard image paste ── editorBody.addEventListener('paste', async e => { const items = e.clipboardData?.items; if (!items) return; for (const item of items) { if (!item.type.startsWith('image/')) continue; e.preventDefault(); const file = item.getAsFile(); if (!file) return; // Insert placeholder while uploading const placeholder = '![uploading...]()'; const pos = editorBody.selectionStart; editorBody.setRangeText(placeholder, pos, editorBody.selectionEnd, 'end'); updatePreview(); // Upload const form = new FormData(); form.append('file', file, 'paste.' + (file.type.split('/')[1] || 'png')); try { const res = await fetch('api/upload', { method: 'POST', body: form }); const text = await res.text(); console.log('Upload response:', res.status, text); const data = JSON.parse(text); if (data.error) throw new Error(data.error); if (data.url) { const md = `![${data.name}](${data.url})`; const current = editorBody.value; const idx = current.indexOf(placeholder); if (idx !== -1) { editorBody.value = current.substring(0, idx) + md + current.substring(idx + placeholder.length); editorBody.selectionStart = editorBody.selectionEnd = idx + md.length; } } } catch (err) { console.error('Upload failed:', err); // Replace placeholder with error hint const current = editorBody.value; const idx = current.indexOf(placeholder); if (idx !== -1) { editorBody.value = current.substring(0, idx) + current.substring(idx + placeholder.length); editorBody.selectionStart = editorBody.selectionEnd = idx; } } updatePreview(); return; } }); // ── Search ── const searchOverlay = document.getElementById('search'); const searchInput = document.getElementById('search-input'); const searchResults = document.getElementById('search-results'); const searchFooter = document.getElementById('search-footer'); const searchPreviewEmpty = document.getElementById('search-preview-empty'); const searchPreviewContent = document.getElementById('search-preview-content'); const searchCloseBtn = document.getElementById('search-close'); let searchActive = -1, searchHits = []; function openSearch() { closeTasks(); searchInput.value = ''; searchResults.innerHTML = ''; searchFooter.classList.add('hidden'); searchActive = -1; searchOverlay.classList.add('open'); searchInput.focus(); clearSearchPreview(); renderSearchHome(); } function closeSearch() { searchOverlay.classList.remove('open'); } function clearSearchPreview() { searchPreviewEmpty.classList.remove('hidden'); searchPreviewContent.classList.add('hidden'); } function updateSearchPreview() { const card = searchHits[searchActive]; if (!card) { clearSearchPreview(); return; } searchPreviewEmpty.classList.add('hidden'); searchPreviewContent.classList.remove('hidden'); document.getElementById('search-preview-color').style.background = card.color; const q = searchInput.value.trim(); const words = q ? q.toLowerCase().split(/\s+/).filter(Boolean) : []; document.getElementById('search-preview-title').innerHTML = words.length ? highlight(esc(card.title), words) : esc(card.title); let bodyHtml = renderMd(card.body); if (words.length) { // Highlight search words in rendered HTML (skip inside tags) for (const w of words) { const re = new RegExp('(>)([^<]*?)(' + escRe(w) + ')', 'gi'); bodyHtml = bodyHtml.replace(re, '$1$2$3'); } } document.getElementById('search-preview-body').innerHTML = bodyHtml; document.getElementById('search-preview-meta').textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at + (card.bucket_name ? ' | bucket: ' + card.bucket_name : ''); } function getSearchPool() { const bv = searchBucketFilter.value; if (!bv) return currentCards; if (bv === 'none') return currentCards.filter(c => !c.bucket_id); return currentCards.filter(c => c.bucket_id == bv); } function renderSearchHome() { const recent = [...getSearchPool()].sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')).slice(0, 6); if (!recent.length) { searchResults.innerHTML = `
no cards yet
`; return; } searchResults.innerHTML = `
recent
${recent.map((c, i) => searchHitHtml(c, i, null)).join('')}`; searchHits = recent; searchActive = recent.length ? 0 : -1; searchFooter.classList.remove('hidden'); bindSearchHits(); updateSearchActive(); updateSearchPreview(); } // ── Fuzzy search engine ── function fuzzyMatch(needle, haystack) { if (needle.length < 3) return null; // minimum 3 chars for fuzzy let ni = 0, hi = 0, score = 0, streak = 0; while (ni < needle.length && hi < haystack.length) { if (needle[ni] === haystack[hi]) { streak++; score += streak * 2; if (hi === 0 || /[\s\-_./]/.test(haystack[hi - 1])) score += 10; if (hi < 5) score += 5 - hi; ni++; } else { streak = 0; } hi++; } if (ni < needle.length) return null; score -= (haystack.length - needle.length) * 0.3; return score; } function countSubstring(str, sub) { let count = 0, pos = 0; while ((pos = str.indexOf(sub, pos)) !== -1) { count++; pos += sub.length; } return count; } function searchCards(query) { const q = query.toLowerCase().trim(); if (!q) { renderSearchHome(); return; } const words = q.split(/\s+/).filter(Boolean); const pool = getSearchPool(); const scored = pool.map(c => { const title = (c.title || '').toLowerCase(); const body = stripMd(c.body || '').toLowerCase(); let score = 0, matched = false; if (title.includes(q)) { score += 150; matched = true; } if (body.includes(q)) { score += 40 + countSubstring(body, q) * 10; matched = true; } let allWordsMatch = true; for (const w of words) { const inTitle = title.includes(w), inBody = body.includes(w); if (inTitle) { score += 30; if (new RegExp('(^|[\\s\\-_./])' + escRe(w)).test(title)) score += 20; if (title.startsWith(w)) score += 15; score += countSubstring(title, w) * 5; } if (inBody) { score += 8 + countSubstring(body, w) * 3; } if (!inTitle && !inBody) allWordsMatch = false; } if (allWordsMatch) { score += 50; matched = true; } const tf = fuzzyMatch(q, title), bf = fuzzyMatch(q, body); if (tf !== null) { score += 20 + tf * 2; matched = true; } if (bf !== null) { score += 5 + bf * 0.5; matched = true; } if (!matched) { let fh = 0; for (const w of words) { if (fuzzyMatch(w, title) !== null) { score += 10; fh++; } if (fuzzyMatch(w, body) !== null) { score += 3; fh++; } } if (fh > 0) matched = true; } if (!matched) return null; score -= title.length * 0.1; return { card: c, score }; }).filter(Boolean).sort((a, b) => b.score - a.score); searchHits = scored.map(s => s.card); searchActive = scored.length ? 0 : -1; if (!scored.length) { searchResults.innerHTML = `

no results for "${esc(query)}"

`; searchFooter.classList.add('hidden'); clearSearchPreview(); return; } searchResults.innerHTML = `
${scored.length} result${scored.length > 1 ? 's' : ''}
${scored.map((s, i) => searchHitHtml(s.card, i, words)).join('')}`; searchFooter.classList.remove('hidden'); bindSearchHits(); updateSearchActive(); updateSearchPreview(); } function searchHitHtml(card, idx, words) { const title = words ? highlight(esc(card.title), words) : esc(card.title); const plain = stripMd(card.body || ''); let snippet = ''; if (words && plain) { const lower = plain.toLowerCase(); const fullQ = words.join(' '); const winSize = 160; // Score each position: exact phrase > multi-word density > single word let best = 0, bestScore = -1; for (let i = 0; i < lower.length; i += 10) { const win = lower.substring(i, i + winSize); let ws = 0; // Exact full query in window if (win.includes(fullQ)) ws += 50; // Per-word: count occurrences, weight by proximity to each other let firstHit = -1, lastHit = -1; for (const w of words) { let p = 0, hits = 0; while ((p = win.indexOf(w, p)) !== -1) { hits++; if (firstHit === -1 || p < firstHit) firstHit = p; if (p > lastHit) lastHit = p + w.length; p += w.length; } ws += hits * 8; } // Proximity bonus: words close together score higher if (firstHit !== -1 && lastHit !== -1) { const span = lastHit - firstHit; if (span > 0) ws += Math.max(0, 30 - span / 2); } if (ws > bestScore) { bestScore = ws; best = i; } } // Center the snippet around the best match const center = Math.min(best + winSize / 2, lower.length); const start = Math.max(0, Math.floor(center - winSize / 2)); const end = Math.min(plain.length, start + winSize); snippet = (start > 0 ? '...' : '') + plain.substring(start, end).trim() + (end < plain.length ? '...' : ''); snippet = highlight(esc(snippet), words); } else { snippet = esc(plain.substring(0, 120)) + (plain.length > 120 ? '...' : ''); } return `
${title}
${snippet}
`; } function highlight(html, words) { let result = html; for (const w of words) result = result.replace(new RegExp('(' + escRe(w) + ')', 'gi'), '$1'); if (!result.includes('')) { const full = words.join(''); let fi = 0, out = ''; for (let i = 0; i < html.length && fi < full.length; i++) { if (html[i].toLowerCase() === full[fi]) { out += '' + html[i] + ''; fi++; } else { out += html[i]; } } out += html.substring(out.replace(/<\/?mark>/g, '').length); result = out; } return result; } function bindSearchHits() { searchResults.querySelectorAll('.search-hit').forEach(el => { el.addEventListener('click', () => { const idx = parseInt(el.dataset.idx); if (searchActive === idx && searchHits[idx]) openDetail(searchHits[idx].id); else { searchActive = idx; updateSearchActive(); } }); el.addEventListener('dblclick', () => { const idx = parseInt(el.dataset.idx); if (searchHits[idx]) openDetail(searchHits[idx].id); }); }); } function updateSearchActive() { searchResults.querySelectorAll('.search-hit').forEach((el, i) => { el.classList.toggle('active', i === searchActive); if (i === searchActive) el.scrollIntoView({ block: 'nearest' }); }); updateSearchPreview(); } searchInput.addEventListener('input', () => searchCards(searchInput.value)); searchBucketFilter.addEventListener('change', () => searchCards(searchInput.value)); searchInput.addEventListener('keydown', e => { const hits = searchResults.querySelectorAll('.search-hit'); if (e.key === 'ArrowDown') { e.preventDefault(); searchActive = Math.min(searchActive + 1, hits.length - 1); updateSearchActive(); } else if (e.key === 'ArrowUp') { e.preventDefault(); searchActive = Math.max(searchActive - 1, 0); updateSearchActive(); } else if (e.key === 'Enter' && searchActive >= 0 && searchHits[searchActive]) { e.preventDefault(); openDetail(searchHits[searchActive].id); } }); searchOverlay.addEventListener('click', e => { if (e.target === searchOverlay) closeSearch(); }); document.getElementById('search-btn').addEventListener('click', openSearch); if (searchCloseBtn) searchCloseBtn.addEventListener('click', closeSearch); // ── Global tasks ── const tasksOverlay = document.getElementById('tasks'); const tasksBtn = document.getElementById('tasks-btn'); const tasksCloseBtn = document.getElementById('tasks-close'); const tasksStatusFilter = document.getElementById('tasks-status-filter'); const tasksList = document.getElementById('tasks-list'); const tasksSummary = document.getElementById('tasks-summary'); const tasksOpenCount = document.getElementById('tasks-open-count'); function collectTasks() { const all = []; for (const card of currentCards) { const body = card.body || ''; const taskRe = /- \[([ xX])\][ \t]*([^\n\r]*)/g; let m; let idx = 0; while ((m = taskRe.exec(body)) !== null) { all.push({ cardId: card.id, cardTitle: card.title || 'untitled', cardColor: card.color || '#94a3b8', bucketId: card.bucket_id, bucketName: card.bucket_name || '', done: m[1].toLowerCase() === 'x', text: (m[2] || '').trim() || '(empty task)', todoIndex: idx, }); idx++; } } return all; } function filterTasks(tasks) { const status = tasksStatusFilter?.value || 'open'; const bucket = tasksBucketFilter?.value || ''; return tasks.filter(t => { if (status === 'open' && t.done) return false; if (status === 'done' && !t.done) return false; if (bucket === 'none') return !t.bucketId; if (bucket !== '' && t.bucketId != bucket) return false; return true; }); } function refreshTasksView() { if (!tasksOpenCount) return; const tasks = collectTasks(); const openCount = tasks.filter(t => !t.done).length; tasksOpenCount.textContent = String(openCount); if (!tasksOverlay || !tasksOverlay.classList.contains('open') || !tasksList || !tasksSummary) return; const filtered = filterTasks(tasks); const doneCount = tasks.length - openCount; tasksSummary.textContent = `open: ${openCount} | done: ${doneCount} | showing: ${filtered.length}`; if (!filtered.length) { tasksList.innerHTML = `

no tasks in this filter

`; return; } tasksList.innerHTML = filtered.map(t => { const bucket = t.bucketName ? esc(t.bucketName) : 'unsorted'; return `
${esc(t.text)}
`; }).join(''); tasksList.querySelectorAll('[data-task-toggle]').forEach(btn => { btn.addEventListener('click', async e => { e.preventDefault(); e.stopPropagation(); const [cardId, todoIdx] = btn.dataset.taskToggle.split(':'); await toggleTask(cardId, Number(todoIdx)); }); }); tasksList.querySelectorAll('[data-task-open]').forEach(btn => { btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); closeTasks(); openDetail(btn.dataset.taskOpen); }); }); } async function toggleTask(cardId, todoIdx) { const card = currentCards.find(c => c.id == cardId); if (!card) return; let idx = 0; let changed = false; const nextBody = (card.body || '').replace(/- \[([ xX])\]/g, (m, state) => { if (idx++ !== todoIdx) return m; changed = true; return `- [${state.toLowerCase() === 'x' ? ' ' : 'x'}]`; }); if (!changed) return; const updated = await api(`api/cards/${cardId}`, { method: 'PATCH', body: { body: nextBody } }); if (updated && updated.id) { const pos = currentCards.findIndex(c => c.id == updated.id); if (pos !== -1) currentCards[pos] = updated; } else { card.body = nextBody; } if (viewingCard && viewingCard.id == cardId) { const latest = currentCards.find(c => c.id == cardId); if (latest) { viewingCard = latest; if (detail.classList.contains('open')) showDetailContent(latest); } } renderBoard(); } function openTasks() { if (!tasksOverlay) return; if (searchOverlay.classList.contains('open')) closeSearch(); tasksOverlay.classList.add('open'); refreshTasksView(); } function closeTasks() { if (tasksOverlay) tasksOverlay.classList.remove('open'); } if (tasksBtn) tasksBtn.addEventListener('click', openTasks); if (tasksCloseBtn) tasksCloseBtn.addEventListener('click', closeTasks); if (tasksOverlay) tasksOverlay.addEventListener('click', e => { if (e.target === tasksOverlay) closeTasks(); }); if (tasksStatusFilter) tasksStatusFilter.addEventListener('change', refreshTasksView); if (tasksBucketFilter) tasksBucketFilter.addEventListener('change', refreshTasksView); // ── Global keyboard ── document.addEventListener('keydown', e => { const anyOpen = modal.classList.contains('open') || detail.classList.contains('open') || searchOverlay.classList.contains('open') || tasksOverlay?.classList.contains('open'); const dialogOpen = dialogOverlay.classList.contains('open'); if (dialogOpen) return; // dialog handles its own keys if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); searchOverlay.classList.contains('open') ? closeSearch() : openSearch(); return; } if (e.key === 'Escape') { if (detail.classList.contains('open')) closeDetail(); else if (modal.classList.contains('open')) closeModal(); else if (searchOverlay.classList.contains('open')) closeSearch(); else if (tasksOverlay?.classList.contains('open')) closeTasks(); return; } if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); saveCard(); return; } if (e.key === '/' && !anyOpen && document.activeElement === document.body) { e.preventDefault(); openSearch(); } // Arrow keys navigate group in detail view if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && detail.classList.contains('open') && viewingCard) { const groupCards = getGroupCards(viewingCard); if (!groupCards || groupCards.length < 2) return; const idx = groupCards.findIndex(c => c.id == viewingCard.id); if (idx === -1) return; e.preventDefault(); const next = e.key === 'ArrowRight' ? groupCards[(idx + 1) % groupCards.length] : groupCards[(idx - 1 + groupCards.length) % groupCards.length]; showDetailCard(next.id); } }); // ── Export / Import ── const exportBtn = document.getElementById('export-btn'); if (exportBtn) { exportBtn.addEventListener('click', () => { window.location.href = 'api/export'; }); } const importBtn = document.getElementById('import-btn'); const importFile = document.getElementById('import-file'); if (importBtn && importFile) { importBtn.addEventListener('click', () => { importFile.click(); }); importFile.addEventListener('change', async e => { const file = e.target.files?.[0]; if (!file) return; const ok = await showDialog({ title: 'import_data', message: 'Alle bestehenden Daten werden ersetzt. Fortfahren?', danger: true, okLabel: 'import', }); if (!ok) { e.target.value = ''; return; } const form = new FormData(); form.append('file', file); try { const res = await fetch('api/import', { method: 'POST', body: form }); const text = await res.text(); const data = JSON.parse(text); if (data.error) throw new Error(data.error); await loadBuckets(); await reloadAll(); } catch (err) { console.error('Import failed:', err); } e.target.value = ''; }); } // ── Auth ── const logoutBtn = document.getElementById('logout-btn'); if (logoutBtn) { logoutBtn.addEventListener('click', async () => { const ok = await showDialog({ title: 'logout', message: 'Abmelden?', okLabel: 'logout', danger: true }); if (!ok) return; await fetch('api/auth/logout', { method: 'POST' }); location.reload(); }); } async function checkAuth() { const res = await fetch('api/auth/status'); const data = await res.json(); if (data.setup) { // First-time setup let pw = null; while (!pw || pw.length < 4) { pw = await showDialog({ title: 'setup', message: 'Willkommen bei hyprcrd. Setze ein Passwort:', input: 'password', placeholder: '> passwort (min. 4 zeichen)...', okLabel: 'set', cancelLabel: '', }); if (pw && pw.length < 4) pw = null; } const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }), }); const d = await r.json(); if (d.error) { location.reload(); return; } } else if (!data.logged_in) { // Login required let ok = false; while (!ok) { const pw = await showDialog({ title: 'login', message: '', input: 'password', placeholder: '> passwort...', okLabel: 'login', cancelLabel: '', }); if (!pw) continue; const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }), }); const d = await r.json(); if (d.ok) { ok = true; } } } // Authenticated — load app loadBuckets(); loadCards(); } // ── Boot ── checkAuth();