// ── Board rendering & card interactions ── import { $, esc, api } from './util.js'; import { state, sortedCards, parseTags, tagChipHtml, getChildren, getRecent, getFavorites, isFavorite, SORT_MODES, SORT_ICONS, CARD_COLORS } from './state.js'; import { stripMd, getTodos, firstImage, leadingImage, renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js'; import { pushReorder, pushCardEdit, pushBatch, snapshotCard } from './undo.js'; import { getCalConfig } from './caldav.js'; import { addProvider as addNotifProvider, render as renderNotifs } from './notifications.js'; import { bindCheckboxes, bindDetailImageFullscreen } from './detail.js'; const board = $('board'); const boardPreview = $('board-preview'); board?.addEventListener('contextmenu', e => { if (e.target.closest('[data-id]') || e.target.closest('.superview-item')) return; e.preventDefault(); window._openModal(null); }); const boardPreviewBody = $('board-preview-body'); const boardPreviewTitle = $('board-preview-title'); const boardPreviewColor = $('board-preview-color'); const boardPreviewTags = $('board-preview-tags'); const boardPreviewMeta = $('board-preview-meta'); let activeDragEl = null; let dragGhost = null; // Set of card ids currently being dragged. Module-scoped so a poll-driven // re-render (caldav/collab) can re-apply the `.card-dragging` ghost class // to the matching DOM nodes after `board.innerHTML = …` wipes them. Without // this the dragged card would visually pop back to its normal state every // time a background poll completed mid-drag. const draggingCardIds = new Set(); // Tracks every card id we've ever rendered. On the first render we just // snapshot whatever's currently in state.cards (everything counts as // "already known"); after that, the diff against this set tells us // which cards just arrived from a poll / sync. New cards get pushed into // state.unseenCardIds and either pulse immediately (if we're already on // the all view) or wait for the next visit to the all tab to pulse. let knownCardIds = null; /** Animate card removal: adds .card-removing to each matching card * element and waits for the CSS animation to finish before resolving. * Call this BEFORE the API delete + _reloadAll so the user sees the * card shrink/fade out instead of just vanishing. */ export function animateCardRemoval(ids) { if (!ids || !ids.length) return Promise.resolve(); const els = ids.map(id => board.querySelector(`[data-id="${id}"]`)).filter(Boolean); if (!els.length) return Promise.resolve(); for (const el of els) el.classList.add('card-removing'); return new Promise(r => setTimeout(r, 260)); } /** Ghost-and-spinner overlay for cards that are being moved between * buckets. The PATCH + reloadAll round-trip used to leave the original * tile sitting stale in the current bucket for a couple hundred ms; * marking them gives immediate feedback. The class comes off naturally * on the next renderBoard() (the DOM is rebuilt from fresh state). */ export function markCardsMoving(ids) { if (!ids || !ids.length) return; for (const id of ids) { const el = board.querySelector(`[data-id="${id}"]`); if (el) el.classList.add('card-moving'); } } window._markCardsMoving = markCardsMoving; /** Add a pulse class to matching board cards and strip it after the * animation. `cls` controls the variant: 'card-pulse-new' (3×, for * relay-synced cards) or 'card-pulse-local' (1×, for cards the user * just created themselves). */ function pulseCards(ids, cls = 'card-pulse-new') { if (!ids || !ids.size) return; const durationMs = cls === 'card-pulse-local' ? 900 : 2400; requestAnimationFrame(() => { for (const id of ids) { const el = board.querySelector(`[data-id="${id}"]`); if (!el) continue; el.classList.add(cls); setTimeout(() => el.classList.remove(cls), durationMs); } }); } /** Add or remove the unread dot on the fixed `all` bucket-tab. Hidden * while the user is currently looking at the all view. Lives in * board.js (rather than buckets.js) because the trigger is "new card * arrived" — buckets.js doesn't know about that, board.js does. */ function decorateAllUnreadDot() { const allTab = document.querySelector('#bucket-bar [data-bucket="all"]'); if (!allTab) return; const hasUnread = state.unseenCardIds.size > 0 && state.activeBucket !== 'all'; allTab.classList.toggle('has-unread', hasUnread); let dot = allTab.querySelector('.bar-unread-dot'); if (hasUnread && !dot) { dot = document.createElement('span'); dot.className = 'bar-unread-dot'; allTab.appendChild(dot); } else if (!hasUnread && dot) { dot.remove(); } } // ── Multi-select ── const selectedIds = state.selectedIds; const selectBar = $('select-bar'); const selectCount = $('select-count'); function updateSelectBar() { if (!selectBar) return; if (selectedIds.size) { selectBar.classList.remove('hidden'); selectCount.textContent = `${selectedIds.size} selected`; } else { selectBar.classList.add('hidden'); } } function toggleSelect(id) { const sid = String(id); const el = board.querySelector(`[data-id="${id}"]`); if (selectedIds.has(sid)) { selectedIds.delete(sid); el?.classList.remove('card-selected'); el?.querySelector('.card-select-check')?.remove(); } else { selectedIds.add(sid); el?.classList.add('card-selected'); if (el && !el.querySelector('.card-select-check')) { const check = document.createElement('div'); check.className = 'card-select-check'; el.appendChild(check); } } updateSelectBar(); } function clearSelection() { selectedIds.clear(); state.selectionWasBulk = false; board.querySelectorAll('.card-selected').forEach(el => { el.classList.remove('card-selected'); el.querySelector('.card-select-check')?.remove(); }); updateSelectBar(); } export function getSelectedIds() { return [...selectedIds]; } export { clearSelection }; // Delete/Backspace for selected cards is now in the central shortcuts // registry (app.js registerShortcut). It uses getSelectedIds() / // clearSelection() which are already exported. // Clear selection on click outside board (header, footer, etc) document.addEventListener('click', e => { if (!selectedIds.size) return; if (board.contains(e.target)) return; if (selectBar?.contains(e.target)) return; if (document.querySelector('.bucket-picker')?.contains(e.target)) return; clearSelection(); }); $('select-export')?.addEventListener('click', () => { if (!selectedIds.size) return; window.location.href = 'api/export?ids=' + [...selectedIds].join(','); }); $('select-group')?.addEventListener('click', async () => { if (selectedIds.size < 2) return; const ids = [...selectedIds]; const parentId = ids[0]; // First selected becomes parent for (let i = 1; i < ids.length; i++) { await api(`api/cards/${ids[i]}`, { method: 'PATCH', body: { parent_id: parentId } }); } clearSelection(); await window._reloadAll(); }); $('select-join')?.addEventListener('click', async () => { if (selectedIds.size < 2) return; // Dynamic import keeps the board → context-menu edge lazy and avoids // circular import surprises at module load time. joinSelectedCards // reads state.selectedIds + state.selectionWasBulk and handles its // own reload + selection clear. const { joinSelectedCards } = await import('./context-menu.js'); await joinSelectedCards(); }); $('select-bucket')?.addEventListener('click', () => { if (!selectedIds.size) return; // Remove existing picker document.querySelector('.bucket-picker')?.remove(); const btn = $('select-bucket'); const rect = btn.getBoundingClientRect(); const picker = document.createElement('div'); picker.className = 'bucket-picker'; picker.style.cssText = `position:fixed;left:${rect.left}px;bottom:${window.innerHeight - rect.top + 4}px;z-index:200;background:var(--t-surface);border:1px solid var(--t-border);border-radius:4px;box-shadow:0 4px 20px rgba(0,0,0,0.4);min-width:180px;max-height:260px;display:flex;flex-direction:column;`; const list = document.createElement('div'); list.style.cssText = 'overflow-y:auto;flex:1;'; state.buckets.forEach(b => { const item = document.createElement('button'); item.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;padding:6px 12px;background:none;border:none;color:var(--t-muted);font-size:12px;cursor:pointer;text-align:left;'; item.innerHTML = `${esc(b.name)}`; item.addEventListener('mouseenter', () => { item.style.color = 'var(--t-bright)'; }); item.addEventListener('mouseleave', () => { item.style.color = 'var(--t-muted)'; }); item.addEventListener('click', async () => { picker.remove(); for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: b.id } }); clearSelection(); await window._reloadAll(); }); list.appendChild(item); }); picker.appendChild(list); const inputWrap = document.createElement('div'); inputWrap.style.cssText = 'border-top:1px solid var(--t-border);padding:6px;'; const input = document.createElement('input'); input.type = 'text'; input.placeholder = 'new bucket...'; input.style.cssText = 'width:100%;background:var(--t-bg);border:1px solid var(--t-border);color:var(--t-bright);padding:4px 8px;font-size:11px;font-family:var(--t-font-pixel);border-radius:3px;'; input.addEventListener('keydown', async e => { if (e.key !== 'Enter') return; const name = input.value.trim(); if (!name) return; picker.remove(); let bucket = state.buckets.find(b => b.name.toLowerCase() === name.toLowerCase()); if (!bucket) { const created = await api('api/buckets', { method: 'POST', body: { name } }); if (created?.id) bucket = created; } if (!bucket) return; for (const id of selectedIds) await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: bucket.id } }); clearSelection(); await window._reloadAll(); }); inputWrap.appendChild(input); picker.appendChild(inputWrap); document.body.appendChild(picker); input.focus(); const closePicker = e => { if (!picker.contains(e.target) && e.target !== btn) { picker.remove(); cleanup(); } }; const closeOnEsc = e => { if (e.key === 'Escape') { e.stopPropagation(); picker.remove(); cleanup(); } }; const cleanup = () => { document.removeEventListener('mousedown', closePicker); document.removeEventListener('keydown', closeOnEsc, true); }; setTimeout(() => { document.addEventListener('mousedown', closePicker); document.addEventListener('keydown', closeOnEsc, true); }, 0); }); $('select-color')?.addEventListener('click', async () => { if (!selectedIds.size) return; const { showDialog } = await import('./dialog.js'); const color = await showDialog({ title: 'set color', input: true, placeholder: '#38bdf8', okLabel: 'Apply' }); if (!color || !/^#[0-9a-fA-F]{6}$/.test(color.trim())) return; for (const id of selectedIds) { await api(`api/cards/${id}`, { method: 'PATCH', body: { color: color.trim() } }); } clearSelection(); await window._reloadAll(); }); $('select-tag')?.addEventListener('click', async () => { if (!selectedIds.size) return; const { showDialog } = await import('./dialog.js'); const tag = await showDialog({ title: 'add tag', input: true, placeholder: '#tagname', okLabel: 'Add' }); if (!tag) return; const cleanTag = tag.trim().replace(/^#/, ''); if (!cleanTag) return; for (const id of selectedIds) { const card = state.cards.find(c => c.id == id); if (!card) continue; const body = (card.body || '').trimEnd() + ' #' + cleanTag; await api(`api/cards/${id}`, { method: 'PATCH', body: { body } }); } clearSelection(); await window._reloadAll(); }); $('select-unbucket')?.addEventListener('click', async () => { if (!selectedIds.size) return; for (const id of selectedIds) { await api(`api/cards/${id}`, { method: 'PATCH', body: { bucket_id: null } }); } clearSelection(); await window._reloadAll(); }); $('select-delete')?.addEventListener('click', async () => { if (!selectedIds.size) return; if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') { const { showDialog } = await import('./dialog.js'); const ok = await showDialog({ title: 'delete cards', message: `Delete ${selectedIds.size} card${selectedIds.size > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' }); if (!ok) return; } for (const id of selectedIds) { await api(`api/cards/${id}`, { method: 'DELETE' }); } clearSelection(); await window._reloadAll(); }); $('select-clear')?.addEventListener('click', clearSelection); // Custom drag ghost that fades near header document.addEventListener('dragover', e => { if (!dragGhost) return; const header = document.querySelector('header'); if (!header) return; const headerBottom = header.getBoundingClientRect().bottom; const dist = e.clientY - headerBottom; const near = dist < 80; const op = near ? Math.max(0.08, dist / 80 * 0.5) : 0.5; dragGhost.style.opacity = op.toFixed(2); dragGhost.style.borderColor = near ? 'var(--t-accent)' : ''; dragGhost.style.borderWidth = near ? '2px' : ''; dragGhost.style.boxShadow = near ? '0 0 12px var(--t-accent)' : ''; dragGhost.style.left = (e.clientX - dragGhost._offsetX) + 'px'; dragGhost.style.top = (e.clientY - dragGhost._offsetY) + 'px'; }); // ── Sort toggle ── export function updateSortUI() { const btn = $('sort-toggle'); const label = $('sort-label'); const icon = document.querySelector('#sort-toggle i'); const fixedSort = state.activeBucket === 'current' || state.activeBucket === 'journal'; if (btn) btn.style.display = fixedSort ? 'none' : ''; if (label) label.textContent = state.sortMode; if (icon) icon.className = `ph ${SORT_ICONS[state.sortMode]} text-xs`; } // ── Board split preview ── function showBoardPreview(cardId) { const card = state.cards.find(c => c.id == cardId); if (!card || !state.boardSplitOpen) return; state.boardPreviewCardId = cardId; $('board-preview-empty')?.classList.add('hidden'); $('board-preview-content')?.classList.remove('hidden'); if (boardPreviewColor) boardPreviewColor.style.background = card.color; if (boardPreviewTitle) boardPreviewTitle.textContent = card.title; const doneBadge = $('board-preview-done-badge'); if (doneBadge) doneBadge.classList.toggle('hidden', !parseTags(card).includes('done')); if (boardPreviewBody) { boardPreviewBody.innerHTML = renderMd(card.body); addCodeCopyButtons(boardPreviewBody); formatTodoItems(boardPreviewBody); bindDetailImageFullscreen(boardPreviewBody); bindCheckboxes(boardPreviewBody, card); } const tags = parseTags(card); if (boardPreviewTags) { if (tags.length) { boardPreviewTags.classList.remove('hidden'); boardPreviewTags.innerHTML = tags.map(t => tagChipHtml(t)).join(''); } else { boardPreviewTags.classList.add('hidden'); } } if (boardPreviewMeta) { boardPreviewMeta.textContent = '> ' + (card.updated_at || card.created_at || '').substring(0, 10) + (card.bucket_name ? ' | ' + card.bucket_name : ''); } // Highlight active card board.querySelectorAll('.board-card').forEach(el => el.classList.toggle('preview-active', el.dataset.id == cardId)); } function closeBoardPreview() { boardPreview?.classList.add('hidden'); state.boardSplitOpen = false; localStorage.setItem('hyprcrd-board-split', '0'); // The toolbar's active state lives on the `.active` class — the // previous `text-t-accent` removal was a no-op since that class // isn't what toggleBoardSplit uses. const btn = $('board-split-toggle'); if (btn) btn.classList.remove('active'); board.querySelectorAll('.board-card').forEach(el => el.classList.remove('preview-active')); state.boardPreviewCardId = null; resetBoardPreview(); } function resetBoardPreview() { $('board-preview-empty')?.classList.remove('hidden'); $('board-preview-content')?.classList.add('hidden'); if (boardPreviewTitle) boardPreviewTitle.textContent = ''; if (boardPreviewBody) boardPreviewBody.innerHTML = ''; } function toggleBoardSplit() { state.boardSplitOpen = !state.boardSplitOpen; localStorage.setItem('hyprcrd-board-split', state.boardSplitOpen ? '1' : '0'); boardPreview?.classList.toggle('hidden', !state.boardSplitOpen); const btn = $('board-split-toggle'); if (btn) btn.classList.toggle('active', state.boardSplitOpen); if (!state.boardSplitOpen) { board.querySelectorAll('.board-card').forEach(el => el.classList.remove('preview-active')); state.boardPreviewCardId = null; } } $('board-split-toggle')?.addEventListener('click', toggleBoardSplit); $('board-preview-close')?.addEventListener('click', closeBoardPreview); $('board-preview-edit')?.addEventListener('click', () => { const card = state.cards.find(c => c.id == state.boardPreviewCardId); if (card) window._openModal(card); }); // Init split state if (state.boardSplitOpen) { boardPreview?.classList.remove('hidden'); const btn = $('board-split-toggle'); if (btn) btn.classList.add('active'); } $('superview-toggle')?.addEventListener('click', () => { state.superView = !state.superView; localStorage.setItem('hyprcrd-superview', state.superView ? '1' : '0'); const btn = $('superview-toggle'); const icon = btn?.querySelector('i'); if (icon) icon.className = `ph ${state.superView ? 'ph-squares-four' : 'ph-list'} text-sm`; if (btn) btn.classList.toggle('active', state.superView); renderBoard(); }); if (state.superView) { const btn = $('superview-toggle'); const icon = btn?.querySelector('i'); if (icon) icon.className = 'ph ph-squares-four text-sm'; if (btn) btn.classList.add('active'); } function initToggle(id, stateKey, lsKey, iconOff, iconOn) { const btn = $(id); if (!btn) return; const apply = () => { const icon = btn.querySelector('i'); if (icon) icon.className = `ph ${state[stateKey] ? iconOn : iconOff} text-sm`; btn.classList.toggle('active', state[stateKey]); }; btn.addEventListener('click', () => { state[stateKey] = !state[stateKey]; localStorage.setItem(lsKey, state[stateKey] ? '1' : '0'); apply(); renderBoard(); }); apply(); } initToggle('hide-done', 'hideDone', 'hyprcrd-hide-done', 'ph-check-circle', 'ph-check-square'); initToggle('hide-termine', 'hideTermine', 'hyprcrd-hide-termine', 'ph-calendar-x', 'ph-calendar-check'); initToggle('hide-tagged', 'hideTagged', 'hyprcrd-hide-tagged', 'ph-hash', 'ph-hash-straight'); $('sort-toggle')?.addEventListener('click', () => { state.sortMode = SORT_MODES[(SORT_MODES.indexOf(state.sortMode) + 1) % SORT_MODES.length]; localStorage.setItem('hyprcrd-sort', state.sortMode); updateSortUI(); renderBoard(); }); export function renderCardHtml(c, inGroup = false, recent = []) { const loaded = c.body != null; const showThumbs = localStorage.getItem('hyprcrd-show-thumbnails') !== '0'; const coverImg = loaded && showThumbs ? leadingImage(c.body) : null; const thumbImg = coverImg ? null : (loaded && showThumbs ? firstImage(c.body) : null); const children = getChildren(c.id); const isGroup = !inGroup && children.length > 0; const todos = loaded ? getTodos(c.body) : null; const todoPct = todos ? Math.round(todos.done / todos.total * 100) : 0; const hasCover = !!coverImg; const tags = parseTags(c); const isTermin = tags.includes('termin') || tags.includes('geburtstag'); let calCfg = null; if (isTermin) { for (const t of tags) { if (t && t !== 'termin' && t !== 'geburtstag') { const cfg = getCalConfig(t); if (cfg) { calCfg = cfg; break; } } } } const cardColor = calCfg?.color || c.color; const calIcon = isTermin ? (calCfg?.icon || 'calendar-dots') : null; const overdue = c.due_date && c.due_date < new Date().toISOString().substring(0, 10); const barColor = overdue ? 'var(--t-danger)' : cardColor; return `
${hasCover ? '
' : ''} ${tags.includes('done') && localStorage.getItem('hyprcrd-show-done-badge') !== '0' ? '
' : ''}
${isGroup ? `
${children.length + 1}
` : ''} ${thumbImg ? `
` : ''}
${calIcon ? `` : ''} ${isFavorite(c.id) ? `` : ''} ${todos ? `` : ''}

${esc(c.title)}

${todos ? `
${Array.from({length: todos.total}, (_, i) => `
`).join('')}
` : ''} ${loaded ? (localStorage.getItem('hyprcrd-body-lines') === '0' ? '' : `

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

`) : `
`} ${tags.length && localStorage.getItem('hyprcrd-show-tags') !== '0' ? `
${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `+${tags.length - 5}` : ''}
` : ''}
${c.due_date ? `${c.due_date}` : `${(c.updated_at || c.created_at || '').substring(0, 10)}`} ${c.bucket_name ? `${esc(c.bucket_name)}` : ''} ${Number(c.read_push) ? '' : ''}
`; } // ── Render ── // // Public renderBoard() is coalesced via requestAnimationFrame: multiple // callers in the same tick (cache paint → meta paint → body paint → // plugin-hook re-render → ...) collapse into a single actual render on // the next frame. Dropped the "three innerHTML swaps per reload" cost // that showed up as the "Gekringel" during boot. Callers that need the // DOM in a specific state before doing something else can call // renderBoardSync() explicitly — but in practice every existing caller // just wants the board to reflect the latest state. let rafScheduled = 0; export function renderBoard() { if (rafScheduled) return; rafScheduled = requestAnimationFrame(() => { rafScheduled = 0; renderBoardSync(); }); } export function renderBoardSync() { if (rafScheduled) { cancelAnimationFrame(rafScheduled); rafScheduled = 0; } renderBoardImpl(); } function renderBoardImpl() { // Diff state.cards against the last seen ids so we can flag arrivals // as "unseen". The very first renderBoard runs with state.cards = [] // (initial empty state, before the API has returned), so we wait // until knownCardIds is non-empty before doing the diff — otherwise // the first real load looks like "every card just arrived" and the // entire board pulses on page open. const currentIds = new Set(state.cards.map(c => String(c.id))); const newLocalIds = new Set(); if (knownCardIds && knownCardIds.size > 0) { for (const id of currentIds) { if (knownCardIds.has(id)) continue; // Locally-created cards go into a separate set — they get a // 1× pulse but must NOT trigger the unread dot on the all-tab. if (state.recentlyCreatedIds.has(id)) newLocalIds.add(id); else state.unseenCardIds.add(id); } } knownCardIds = currentIds; let cards; if (state.activeGroup) { const parent = state.cards.find(c => c.id == state.activeGroup); cards = parent ? [parent, ...getChildren(parent.id)] : []; } else { if (state.activeBucket === 'current') { // Current: recently opened/edited cards (not just synced) const recent = getRecent(); const now = Date.now(); const currentDays = parseInt(localStorage.getItem('hyprcrd-current-days') || '2'); const freshMs = currentDays * 24 * 60 * 60 * 1000; cards = state.cards.filter(c => { const tags = (c.tags || '').split(','); if (tags.includes('geburtstag')) return false; // Recently opened (tracked by markRecent) if (recent.includes(String(c.id))) return true; // Recently edited locally (not just synced — check synced_at vs updated_at) const updated = new Date(c.updated_at || c.created_at || 0).getTime(); const includeSynced = localStorage.getItem('hyprcrd-current-include-synced') === '1'; if (!includeSynced) { const synced = c.caldav_synced_at ? new Date(c.caldav_synced_at).getTime() : 0; if (synced && Math.abs(updated - synced) < 3000) return false; } return (now - updated) < freshMs; }); cards.sort((a, b) => (b.updated_at || b.created_at || '').localeCompare(a.updated_at || a.created_at || '')); } else { cards = state.cards.filter(c => !c.parent_id); if (state.activeBucket === 'favorites') { const favs = getFavorites(); cards = cards.filter(c => favs.includes(String(c.id))); } else if (state.activeBucket === 'journal') { const today = new Date().toISOString().substring(0, 10); cards = cards.filter(c => /^\d{4}-\d{2}-\d{2}/.test(c.title) && c.title.substring(0, 10) <= today && !(c.tags || '').split(',').includes('geburtstag')); cards.sort((a, b) => b.title.localeCompare(a.title)); } else if (state.activeBucket === 'none') { cards = cards.filter(c => !c.bucket_id && !/^\d{4}-\d{2}-\d{2}/.test(c.title)); } else if (state.activeBucket !== 'all') { cards = cards.filter(c => c.bucket_id == state.activeBucket); } } } // Hide appointments + birthdays (tag-based and caldav-synced) if (state.hideTermine) { cards = cards.filter(c => { const t = parseTags(c); return !t.includes('termin') && !t.includes('geburtstag') && !c.caldav_uid; }); } // Hide done if (state.hideDone) { cards = cards.filter(c => !parseTags(c).includes('done')); } // Hide tagged (cards with any tags) if (state.hideTagged) { cards = cards.filter(c => !c.tags); } // Tag filter (intersects with bucket filter) — hierarchical: #nase matches #nase/popel if (state.activeTags.length) { cards = cards.filter(c => { const ct = parseTags(c); return state.activeTags.every(at => ct.some(t => t === at || t.startsWith(at + '/'))); }); } // Current and journal have fixed sort order, skip global sort if (state.activeBucket !== 'current' && state.activeBucket !== 'journal') { cards = sortedCards(cards); } updateSortUI(); window._refreshTasks?.(); hideFan(); renderGroupBar(cards); if (!cards.length) { board.style.display = 'flex'; board.style.alignItems = 'center'; board.style.justifyContent = 'center'; // Distinguish "genuinely empty" from "haven't heard back yet". // Blurting "no cards found" during the initial load is scary // on boards the user knows aren't empty. if (!state.cardsLoaded) { board.innerHTML = `

loading…

`; } else { board.innerHTML = `
`; } const fc = $('footer-count'); if (fc) fc.textContent = `0 cards · ${state.cards.length} total`; return; } const recent = getRecent(); const inGroup = !!state.activeGroup; if (state.superView) { // Single-column list view — override board grid board.style.display = 'block'; board.innerHTML = `
` + cards.map(c => { const tags = parseTags(c); const isTermin = tags.includes('termin') || tags.includes('geburtstag'); let calCfg = null; if (isTermin) { for (const t of tags) { if (t !== 'termin' && t !== 'geburtstag') { const cfg = getCalConfig(t); if (cfg) { calCfg = cfg; break; } } } } const dotColor = calCfg?.color || c.color; const icon = isTermin ? (calCfg?.icon || 'calendar-dots') : (isFavorite(c.id) ? 'star' : null); const iconColor = isTermin ? dotColor : (isFavorite(c.id) ? '#fbbf24' : 'var(--t-dim)'); // Date column: due_date if set, else date prefix in title, else updated/created const dateStr = c.due_date || ((c.title || '').match(/^\d{4}-\d{2}-\d{2}/)?.[0]) || (c.updated_at || c.created_at || '').substring(0, 10); // Tag chips (excluding termin/geburtstag — those are already // represented by the icon + dot on the left) const visibleTags = tags.filter(t => t !== 'termin' && t !== 'geburtstag'); const tagsHtml = visibleTags.length ? `${visibleTags.slice(0, 4).map(t => `#${esc(t)}`).join('')}${visibleTags.length > 4 ? `+${visibleTags.length - 4}` : ''}` : ''; const bucketHtml = c.bucket_name ? `${esc(c.bucket_name)}` : ''; return `
${icon ? `` : ''} ${esc(c.title)} ${tagsHtml} ${bucketHtml} ${dateStr}
`; }).join('') + '
'; let hoverPopup = null, hoverTimer = null, hoverCloseTimer = null; function clearHover() { if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } if (hoverCloseTimer) { clearTimeout(hoverCloseTimer); hoverCloseTimer = null; } if (hoverPopup) { hoverPopup.remove(); hoverPopup = null; } } function delayedClose() { hoverCloseTimer = setTimeout(clearHover, 150); } function cancelClose() { if (hoverCloseTimer) { clearTimeout(hoverCloseTimer); hoverCloseTimer = null; } } board.querySelectorAll('.superview-item[data-id]').forEach(el => { el.addEventListener('click', () => { clearHover(); if (state.boardSplitOpen) showBoardPreview(el.dataset.id); else window._openDetail(el.dataset.id); }); el.addEventListener('dblclick', () => { clearHover(); const card = state.cards.find(c => c.id == el.dataset.id); if (card) window._openModal(card); }); el.addEventListener('contextmenu', e => { e.preventDefault(); clearHover(); const card = state.cards.find(c => c.id == el.dataset.id); if (card) window._showContextMenu(e.clientX, e.clientY, card); }); el.addEventListener('mouseenter', () => { cancelClose(); if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; } hoverTimer = setTimeout(() => { if (hoverPopup) { hoverPopup.remove(); hoverPopup = null; } const card = state.cards.find(c => c.id == el.dataset.id); if (!card) return; const rect = el.getBoundingClientRect(); hoverPopup = document.createElement('div'); Object.assign(hoverPopup.style, { position: 'fixed', zIndex: '100', width: '280px', filter: 'drop-shadow(0 8px 24px rgba(0,0,0,0.4))', }); const textEnd = el.querySelector('.superview-title'); const textRect = textEnd ? textEnd.getBoundingClientRect() : rect; const left = textRect.right + 8; const useLeft = left + 290 < window.innerWidth; hoverPopup.style.left = (useLeft ? left : textRect.left - 288) + 'px'; hoverPopup.style.top = Math.min(rect.top - 20, window.innerHeight - 320) + 'px'; hoverPopup.innerHTML = renderCardHtml(card); hoverPopup.addEventListener('mouseenter', e => { e.stopPropagation(); cancelClose(); }); hoverPopup.addEventListener('mouseleave', delayedClose); hoverPopup.addEventListener('mouseover', e => e.stopPropagation()); // Tag clicks inside hover card hoverPopup.querySelectorAll('.tag-seg[data-tag]').forEach(seg => { seg.addEventListener('click', e => { e.stopPropagation(); const tag = seg.dataset.tag; if (e.ctrlKey || e.metaKey) { const idx = state.activeTags.indexOf(tag); if (idx >= 0) state.activeTags.splice(idx, 1); else state.activeTags.push(tag); } else { state.activeTags = state.activeTags.length === 1 && state.activeTags[0] === tag ? [] : [tag]; } clearHover(); window._renderTagBar?.(); renderBoard(); }); }); // Click on hover card opens detail hoverPopup.querySelector('.board-card')?.addEventListener('click', () => { clearHover(); window._openDetail(card.id); }); document.body.appendChild(hoverPopup); }, 400); }); el.addEventListener('mouseleave', delayedClose); }); const fc = $('footer-count'); if (fc) fc.textContent = `${cards.length} cards · ${state.cards.length} total`; return; } board.style.display = ''; board.style.alignItems = ''; board.style.justifyContent = ''; board.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join(''); bindCardEvents(); // Re-apply the dragging ghost to any card whose drag is still in // flight. Polls (caldav, collab) call _reloadAll → renderBoard mid- // drag, which would otherwise wipe the visual ghost state without // the underlying drag operation noticing. if (draggingCardIds.size) { draggingCardIds.forEach(did => { board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging'); }); } // Restore arrow-key card focus if the focused card is still rendered window._restoreCardFocus?.(); // Restore selection state selectedIds.forEach(id => { const el = board.querySelector(`[data-id="${id}"]`); if (el) { el.classList.add('card-selected'); if (!el.querySelector('.card-select-check')) { const check = document.createElement('div'); check.className = 'card-select-check'; el.appendChild(check); } } }); updateSelectBar(); // Footer count const fc = $('footer-count'); if (fc) fc.textContent = `${cards.length} card${cards.length !== 1 ? 's' : ''} · ${state.cards.length} total`; // Refresh split preview if open if (state.boardSplitOpen && state.boardPreviewCardId) { const stillExists = state.cards.some(c => c.id == state.boardPreviewCardId); if (stillExists) showBoardPreview(state.boardPreviewCardId); else { state.boardPreviewCardId = null; resetBoardPreview(); } } // Notification banner renderNotifs(); // Pulse locally-created cards in ANY bucket (1× subtle pulse). // These never enter unseenCardIds so they can't trigger the all-tab // dot — the user just made them, there's nothing "unseen" about it. // Journal entries are exempt from the pulse entirely — they're daily // log jottings, not "here's a new thing" moments, so the flash // reads as noise. Detection mirrors calendar.js isJournal: title // begins with YYYY-MM-DD, no termin/geburtstag tag, no caldav_uid, // and no " — " suffix (which denotes an appointment). if (newLocalIds.size) { for (const id of newLocalIds) state.recentlyCreatedIds.delete(id); const toPulse = new Set(); for (const id of newLocalIds) { const card = state.cards.find(c => String(c.id) === id); if (!card) continue; const isJournal = /^\d{4}-\d{2}-\d{2}$/.test((card.title || '').trim()) && !/^\d{4}-\d{2}-\d{2}\s*[—-]\s*.+/.test(card.title || '') && !card.caldav_uid && !(card.tags || '').split(',').some(t => t === 'termin' || t === 'geburtstag'); if (!isJournal) toPulse.add(id); } if (toPulse.size) pulseCards(toPulse, 'card-pulse-local'); } // Remote (relay-synced) cards: pulse 3× only when the user is on // the all view. On other views, the unseen ids accumulate and // the all-tab gets a dot until the user opens it. if (state.activeBucket === 'all' && state.unseenCardIds.size) { const remote = new Set(state.unseenCardIds); state.unseenCardIds.clear(); pulseCards(remote, 'card-pulse-new'); } decorateAllUnreadDot(); } // ── Notification providers ── // Upcoming events (birthdays + appointments) addNotifProvider((settings) => { const today = new Date(); const todayStr = today.toISOString().substring(0, 10); const todayLocal = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const lookahead = settings.lookahead || 3; const aheadStr = new Date(todayLocal.getTime() + lookahead * 86400000).toISOString().substring(0, 10); const items = []; for (const c of state.cards) { const tags = (c.tags || '').split(','); const isGeb = tags.includes('geburtstag'); const isTermin = tags.includes('termin'); if (isGeb && !settings.birthdays) continue; if (isTermin && !isGeb && !settings.termine) continue; if (!isGeb && !isTermin) continue; const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/); if (!m) continue; let eventDate = m[1]; if (isGeb) eventDate = todayStr.substring(0, 4) + eventDate.substring(4); if (eventDate < todayStr || eventDate > aheadStr) continue; const name = c.title.replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title; const evParts = eventDate.split('-').map(Number); const evLocal = new Date(evParts[0], evParts[1] - 1, evParts[2]); const daysAway = Math.round((evLocal - todayLocal) / 86400000); const when = daysAway === 0 ? 'today' : daysAway === 1 ? 'tomorrow' : `in ${daysAway}d`; items.push({ id: String(c.id), icon: isGeb ? 'cake' : 'calendar-dots', iconColor: isGeb ? '#fb7185' : 'var(--t-text)', label: name, when, sort: eventDate, type: isGeb ? 'birthday' : 'appointment', onClick: () => window._openDetail(c.id), }); } return items; }); // Due dates approaching or overdue addNotifProvider((settings) => { const today = new Date(); const todayStr = today.toISOString().substring(0, 10); const todayLocal = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const lookahead = settings.lookahead || 3; const aheadStr = new Date(todayLocal.getTime() + lookahead * 86400000).toISOString().substring(0, 10); const items = []; for (const c of state.cards) { if (!c.due_date) continue; // Overdue if (c.due_date < todayStr) { if (!settings.overdue) continue; items.push({ id: 'due-' + c.id, icon: 'warning', iconColor: 'var(--t-danger)', label: c.title, when: 'overdue', sort: '0' + c.due_date, type: 'overdue', onClick: () => window._openDetail(c.id), }); } // Upcoming else if (c.due_date <= aheadStr) { const evParts = c.due_date.split('-').map(Number); const evLocal = new Date(evParts[0], evParts[1] - 1, evParts[2]); const daysAway = Math.round((evLocal - todayLocal) / 86400000); const when = daysAway === 0 ? 'today' : daysAway === 1 ? 'tomorrow' : `in ${daysAway}d`; items.push({ id: 'due-' + c.id, icon: 'clock-countdown', iconColor: daysAway === 0 ? 'var(--t-danger)' : 'var(--t-accent)', label: c.title, when, sort: c.due_date, type: 'due', onClick: () => window._openDetail(c.id), }); } } return items; }); // Overdue tasks (cards with unchecked todos past their date, no due_date set) addNotifProvider((settings) => { if (!settings.overdue) return []; const todayStr = new Date().toISOString().substring(0, 10); const items = []; for (const c of state.cards) { if (c.due_date) continue; // handled by due-date provider above const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/); if (!m || m[1] >= todayStr) continue; if (!c.body || !/- \[ \]/.test(c.body)) continue; const name = c.title.replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title; items.push({ id: 'overdue-' + c.id, icon: 'warning', iconColor: 'var(--t-danger)', label: name, when: 'overdue', sort: '0' + m[1], type: 'overdue', onClick: () => window._openDetail(c.id), }); } return items; }); // ── Group bar ── function renderGroupBar(cards) { const bar = $('group-bar'); if (!bar) return; if (state.activeGroup) { const parent = state.cards.find(c => c.id == state.activeGroup); bar.classList.remove('hidden'); bar.dataset.groupId = String(state.activeGroup); bar.innerHTML = ` ${esc(parent?.title || 'group')} ${cards.length} cards`; $('group-back')?.addEventListener('click', () => { state.activeGroup = null; renderBoard(); }); } else { bar.classList.add('hidden'); delete bar.dataset.groupId; } } // ── Group fan preview ── let fanEl = null; let fanTimer = null; let fanGroupId = null; function showFan(groupEl, parentId) { hideFan(); const parent = state.cards.find(c => c.id == parentId); if (!parent) return; const children = getChildren(parentId); const all = children; const maxShow = 12; const visible = all.slice(0, maxShow); const hasMore = all.length > maxShow; fanGroupId = parentId; fanEl = document.createElement('div'); fanEl.className = 'group-fan'; const cols = Math.min(6, visible.length); fanEl.innerHTML = `
${visible.map(c => { const img = firstImage(c.body); return `
${img ? `
` : ''}
${esc(c.title)}
`; }).join('')}
`; document.body.appendChild(fanEl); // Position below the group card const rect = groupEl.getBoundingClientRect(); const fanW = fanEl.offsetWidth; let left = rect.left + rect.width / 2 - fanW / 2; left = Math.max(8, Math.min(left, innerWidth - fanW - 8)); let top = rect.bottom + 6; if (top + fanEl.offsetHeight > innerHeight - 40) top = rect.top - fanEl.offsetHeight - 6; fanEl.style.left = left + 'px'; fanEl.style.top = top + 'px'; fanEl.querySelectorAll('[data-fan-id]').forEach(card => { card.addEventListener('click', () => { hideFan(); window._openDetail(card.dataset.fanId); }); }); fanEl.addEventListener('mouseleave', hideFan); } function hideFan() { if (fanTimer) { clearTimeout(fanTimer); fanTimer = null; } if (fanEl) { fanEl.remove(); fanEl = null; } fanGroupId = null; } // ── Card event binding ── function bindCardEvents() { // Single insertion-cursor element that lives inside #board (which is // `relative`) and gets repositioned by the card-level dragover handlers // below. Replaces the previous `.card-drop-before::before` / // `.card-drop-after::after` pseudo-elements so the cursor is a real // element in the gap rather than a sliver glued to the side of a tile. let cardDropCursor = board.querySelector('#card-drop-cursor'); if (!cardDropCursor) { cardDropCursor = document.createElement('div'); cardDropCursor.id = 'card-drop-cursor'; board.appendChild(cardDropCursor); } function showCardDropCursor(targetEl, before) { // offsetLeft / offsetTop / offsetHeight are relative to the // offsetParent (#board) so the cursor positions correctly even // when #board is scrolled vertically. const x = before ? targetEl.offsetLeft - 6 : targetEl.offsetLeft + targetEl.offsetWidth + 2; cardDropCursor.style.left = x + 'px'; cardDropCursor.style.top = (targetEl.offsetTop + 8) + 'px'; cardDropCursor.style.height = (targetEl.offsetHeight - 16) + 'px'; cardDropCursor.style.display = 'block'; } function hideCardDropCursor() { cardDropCursor.style.display = 'none'; } // Expose so the global dragend handler (defined outside this function) // can hide the cursor too. board._hideCardDropCursor = hideCardDropCursor; board.querySelectorAll('[data-group]').forEach(badge => { badge.addEventListener('click', e => { e.stopPropagation(); state.activeGroup = badge.dataset.group; renderBoard(); }); }); board.querySelectorAll('.tag-seg[data-tag]').forEach(seg => { seg.addEventListener('click', e => { e.stopPropagation(); const tag = seg.dataset.tag; if (e.ctrlKey || e.metaKey) { const idx = state.activeTags.indexOf(tag); if (idx >= 0) state.activeTags.splice(idx, 1); else state.activeTags.push(tag); } else { state.activeTags = state.activeTags.length === 1 && state.activeTags[0] === tag ? [] : [tag]; } window._renderTagBar?.(); renderBoard(); }); seg.addEventListener('contextmenu', async e => { e.preventDefault(); e.stopPropagation(); const tag = seg.dataset.tag; const cardEl = seg.closest('[data-id]'); if (!cardEl) return; const card = state.cards.find(c => c.id == cardEl.dataset.id); if (!card) return; const newBody = (card.body || '').replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), ''); await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } }); await window._reloadAll?.(); }); }); board.querySelectorAll('[data-id]').forEach(el => { const id = el.dataset.id; const children = getChildren(id); const isGroup = children.length > 0 && !state.activeGroup; let clickTimer = null; // Group fan on long hover if (isGroup) { el.addEventListener('mouseenter', () => { fanTimer = setTimeout(() => showFan(el, id), 800); }); el.addEventListener('mouseleave', e => { // Don't hide if moving to the fan itself if (fanEl && fanEl.contains(e.relatedTarget)) return; if (fanTimer) { clearTimeout(fanTimer); fanTimer = null; } // Delay hiding to allow moving to fan if (fanEl && fanGroupId == id) { setTimeout(() => { if (fanGroupId == id && fanEl && !fanEl.matches(':hover')) hideFan(); }, 200); } }); } el.addEventListener('click', (e) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); toggleSelect(id); return; } if (clickTimer) return; clickTimer = setTimeout(() => { clickTimer = null; if (state.boardSplitOpen) { showBoardPreview(id); } else if (localStorage.getItem('hyprcrd-click-action') === 'edit') { const card = state.cards.find(c => c.id == id); if (card) window._openModal(card); } else { window._openDetail(id); } }, 200); }); el.addEventListener('dblclick', e => { e.preventDefault(); if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; } const card = state.cards.find(c => c.id == id); if (card) window._openModal(card); }); el.addEventListener('contextmenu', e => { e.preventDefault(); e.stopPropagation(); const card = state.cards.find(c => c.id == id); if (card) window._showContextMenu(e.clientX, e.clientY, card); }); // Drag & drop el.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', id); e.dataTransfer.effectAllowed = 'move'; activeDragEl = el; // Determine all dragged cards (selected or just this one) const isMulti = selectedIds.has(String(id)) && selectedIds.size > 1; const dragIds = isMulti ? [...selectedIds] : [String(id)]; // Ghost all dragged cards + remember the ids so a poll-driven // re-render can re-apply the ghost state. draggingCardIds.clear(); dragIds.forEach(did => { draggingCardIds.add(String(did)); board.querySelector(`[data-id="${did}"]`)?.classList.add('card-dragging'); }); // Hide native drag image const empty = document.createElement('div'); empty.style.width = '1px'; empty.style.height = '1px'; empty.style.opacity = '0.01'; document.body.appendChild(empty); e.dataTransfer.setDragImage(empty, 0, 0); setTimeout(() => empty.remove(), 0); // Create stacked ghost const rect = el.getBoundingClientRect(); const container = document.createElement('div'); container.style.cssText = `position:fixed;z-index:9998;pointer-events:none;transition:opacity 0.12s;opacity:0.5;`; container._offsetX = e.clientX - rect.left; container._offsetY = e.clientY - rect.top; if (isMulti) { // Stack effect: offset layers behind const count = Math.min(dragIds.length, 4); for (let i = count - 1; i >= 1; i--) { const layer = document.createElement('div'); layer.style.cssText = `position:absolute;top:${i * 4}px;left:${i * 4}px;width:${rect.width}px;height:${rect.height}px;background:var(--t-surface);border:1px solid var(--t-border);border-radius:inherit;opacity:${0.3 + (count - i) * 0.15};`; container.appendChild(layer); } // Badge const badge = document.createElement('div'); badge.textContent = dragIds.length; badge.style.cssText = `position:absolute;top:-6px;right:-6px;min-width:20px;height:20px;border-radius:10px;background:var(--t-accent);color:var(--t-bg);font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;padding:0 6px;z-index:5;font-family:var(--t-font-pixel,'Silkscreen',cursive);`; container.appendChild(badge); } const clone = el.cloneNode(true); clone.style.cssText = `position:relative;width:${rect.width}px;`; clone.querySelector('.card-select-check')?.remove(); container.appendChild(clone); container.style.left = (e.clientX - container._offsetX) + 'px'; container.style.top = (e.clientY - container._offsetY) + 'px'; document.body.appendChild(container); dragGhost = container; }); el.addEventListener('dragend', () => { // Remove ghost class from all dragged cards board.querySelectorAll('.card-dragging').forEach(c => c.classList.remove('card-dragging')); draggingCardIds.clear(); activeDragEl = null; if (dragGhost) { dragGhost.remove(); dragGhost = null; } board.querySelectorAll('.card-drop-group').forEach(x => x.classList.remove('card-drop-group')); hideCardDropCursor(); }); el.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const rect = el.getBoundingClientRect(); const edge = rect.width * 0.25, x = e.clientX - rect.left; // Three zones (matching the bucket fix): left 25% / right 25% // → insertion cursor in the gap, center 50% → group highlight // on the target card. const isBefore = x < edge; const isAfter = x > rect.width - edge; const isGroup = !isBefore && !isAfter; el.classList.toggle('card-drop-group', isGroup); if (isGroup) { hideCardDropCursor(); } else { showCardDropCursor(el, isBefore); } }); el.addEventListener('dragleave', () => { el.classList.remove('card-drop-group'); // Don't hide the cursor here — the next card's dragover will // reposition it. Hiding on dragleave makes the cursor flicker // as the mouse moves across adjacent cards. Final cleanup // happens on dragend. }); el.addEventListener('drop', e => handleCardDrop(e, el, id)); }); } // ── Card drop handler ── async function handleCardDrop(e, el, targetId) { e.preventDefault(); // Recompute the drop zone from the mouse position rather than reading // it back from CSS classes — the new cursor-based dragover doesn't // tag the target with `.card-drop-before` / `.card-drop-after` anymore, // only `.card-drop-group` for the center hover state. const rect = el.getBoundingClientRect(); const edge = rect.width * 0.25; const x = e.clientX - rect.left; const wasGroup = !(x < edge) && !(x > rect.width - edge); const wasBefore = x < edge; el.classList.remove('card-drop-group'); board.querySelector('#card-drop-cursor')?.style.setProperty('display', 'none'); const dragId = e.dataTransfer.getData('text/plain'); if (!dragId || dragId === targetId) return; if (wasGroup) { const prev = snapshotCard(state.cards.find(c => c.id == dragId)); pushCardEdit('group', dragId, { parent_id: prev.parent_id || null }); await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: targetId } }); await window._reloadAll(); return; } // Compute new order — must mirror renderBoard's filtering let filtered; if (state.activeGroup) { const parent = state.cards.find(c => c.id == state.activeGroup); filtered = parent ? sortedCards([parent, ...getChildren(parent.id)]) : []; } else { const root = state.cards.filter(c => !c.parent_id); if (state.activeBucket === 'favorites') { const favs = getFavorites(); filtered = sortedCards(root.filter(c => favs.includes(String(c.id)))); } else if (state.activeBucket === 'journal') { filtered = root.filter(c => /^\d{4}-\d{2}-\d{2}/.test(c.title)); filtered.sort((a, b) => b.title.localeCompare(a.title)); } else if (state.activeBucket === 'none') { filtered = sortedCards(root.filter(c => !c.bucket_id && !/^\d{4}-\d{2}-\d{2}/.test(c.title))); } else if (state.activeBucket !== 'all' && state.activeBucket !== 'current') { filtered = sortedCards(root.filter(c => c.bucket_id == state.activeBucket)); } else { filtered = sortedCards(root); } } const ids = filtered.map(c => String(c.id)).filter(i => i !== String(dragId)); ids.splice(wasBefore ? ids.indexOf(String(targetId)) : ids.indexOf(String(targetId)) + 1, 0, String(dragId)); // Ungroup if dragging child to main board const dragCard = state.cards.find(c => c.id == dragId); if (!state.activeGroup && dragCard?.parent_id) { await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: null } }); } // Undo snapshot before reorder pushReorder('reorder', filtered); // FLIP capture const oldRects = {}; board.querySelectorAll('[data-id]').forEach(c => { oldRects[c.dataset.id] = c.getBoundingClientRect(); }); await Promise.all(ids.map((cid, i) => api(`api/cards/${cid}`, { method: 'PATCH', body: { position: i } }))); // Swap group parent if needed if (state.activeGroup && ids[0] !== String(state.activeGroup)) { const newP = ids[0], oldP = String(state.activeGroup); await api(`api/cards/${newP}`, { method: 'PATCH', body: { parent_id: null } }); await api(`api/cards/${oldP}`, { method: 'PATCH', body: { parent_id: newP } }); for (const cid of ids.slice(1)) { if (cid !== oldP) await api(`api/cards/${cid}`, { method: 'PATCH', body: { parent_id: newP } }); } state.activeGroup = newP; } state.sortMode = 'manual'; localStorage.setItem('hyprcrd-sort', 'manual'); updateSortUI(); await window._reloadAll(); // FLIP animate board.querySelectorAll('[data-id]').forEach(card => { const old = oldRects[card.dataset.id]; if (!old) return; const nw = card.getBoundingClientRect(); const dx = old.left - nw.left, dy = old.top - nw.top; if (!dx && !dy) return; card.style.transition = 'none'; card.style.transform = `translate(${dx}px,${dy}px)`; requestAnimationFrame(() => { card.style.transition = 'transform 0.25s ease'; card.style.transform = ''; }); }); } // ── Box select ── let boxEl = null; let boxStart = null; let boxActive = false; board.addEventListener('mousedown', e => { // Only start on board background (not on cards or interactive elements) if (e.button !== 0 || e.target !== board) return; if (e.shiftKey) return; const additive = e.ctrlKey || e.metaKey; const priorSelection = additive ? new Set(selectedIds) : null; boxStart = { x: e.clientX, y: e.clientY, scrollTop: board.scrollTop }; boxActive = false; const onMove = ev => { const dx = ev.clientX - boxStart.x; const dy = ev.clientY - boxStart.y; if (!boxActive && Math.abs(dx) < 5 && Math.abs(dy) < 5) return; if (!boxActive) { boxActive = true; if (!additive) clearSelection(); // Mark the selection as bulk so the join action knows to use // the longest-title strategy instead of first-clicked. state.selectionWasBulk = true; boxEl = document.createElement('div'); boxEl.className = 'box-select'; board.appendChild(boxEl); } const boardRect = board.getBoundingClientRect(); const scrollDelta = board.scrollTop - boxStart.scrollTop; const x1 = boxStart.x - boardRect.left; const y1 = boxStart.y - boardRect.top + board.scrollTop; const x2 = ev.clientX - boardRect.left; const y2 = ev.clientY - boardRect.top + board.scrollTop; const left = Math.min(x1, x2); const top = Math.min(y1, y2); const width = Math.abs(x2 - x1); const height = Math.abs(y2 - y1); boxEl.style.left = left + 'px'; boxEl.style.top = top + 'px'; boxEl.style.width = width + 'px'; boxEl.style.height = height + 'px'; // Check intersections const selRect = { left, top, right: left + width, bottom: top + height }; board.querySelectorAll('[data-id]').forEach(card => { const cr = card.getBoundingClientRect(); const cardRect = { left: cr.left - boardRect.left, top: cr.top - boardRect.top + board.scrollTop, right: cr.right - boardRect.left, bottom: cr.bottom - boardRect.top + board.scrollTop, }; const intersects = !(cardRect.right < selRect.left || cardRect.left > selRect.right || cardRect.bottom < selRect.top || cardRect.top > selRect.bottom); const id = card.dataset.id; const wasPrior = priorSelection?.has(id); if (additive) { // Toggle: prior selected + hit by box → deselect; not prior + hit → select const shouldBeSelected = intersects ? !wasPrior : !!wasPrior; if (shouldBeSelected && !selectedIds.has(id)) toggleSelect(id); else if (!shouldBeSelected && selectedIds.has(id)) toggleSelect(id); } else { if (intersects && !selectedIds.has(id)) toggleSelect(id); else if (!intersects && selectedIds.has(id)) toggleSelect(id); } }); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); if (boxEl) { boxEl.remove(); boxEl = null; } if (!boxActive && selectedIds.size) clearSelection(); boxStart = null; boxActive = false; }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); });