Files
hyprcrd/public/js/modules/board.js
T
thandClaude Opus 4.7 4baeee159e v2.0.14 — ghost + spinner on card move
Moving a card to another bucket took a visible round-trip (PATCH +
reloadAll) during which the card sat unchanged in the source bucket,
leaving the user wondering if the drop registered. Now every
bucket-move path marks the affected cards `.card-moving` as soon as
the action fires: 50% opacity, dark scrim and a centred accent-ring
spinner. The next renderBoard() rebuilds the DOM so the class comes
off automatically — no explicit cleanup needed.

Call sites:
- drag-drop onto a bucket tile (single + multi-select)
- drag onto an existing bucket to nest it (auto-super-bucket create)
- context-menu → Bucket → <target>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:44:03 +02:00

1419 lines
65 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── 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 = `<span style="width:8px;height:8px;border-radius:2px;background:${b.color || '#64748b'};flex-shrink:0;"></span><span style="font-family:var(--t-font-pixel);font-size:10px;">${esc(b.name)}</span>`;
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 `
<div class="board-card ${isGroup ? 'card-group ' : ''}card-glow ${hasCover ? 'card-has-cover' : 'bg-t-surface'} border border-t-border cursor-pointer hover:border-t-muted transition-colors relative group"
style="--glow-color:${cardColor}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${overdue ? 'border-bottom:3px solid var(--t-danger)' : recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + cardColor : ''}" draggable="true" data-id="${c.id}">
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
${tags.includes('done') && localStorage.getItem('hyprcrd-show-done-badge') !== '0' ? '<div class="card-done-badge"><i class="ph ph-check"></i></div>' : ''}
<div class="card-bar w-full card-layer ${overdue ? 'card-bar-pulse' : ''}" style="background:${barColor}"></div>
${isGroup ? `<div class="card-group-badge cursor-pointer hover:scale-110 transition-transform" style="background:${cardColor};color:#000" data-group="${c.id}">${children.length + 1}</div>` : ''}
${thumbImg ? `<div class="card-thumb" style="background-image:url('${thumbImg}')"></div>` : ''}
<div class="p-4 card-layer flex flex-col h-full ${thumbImg ? 'pr-16' : ''}">
<div class="flex items-start gap-2 mb-2">
${calIcon ? `<i class="ph ph-${calIcon} text-xs mt-0.5 shrink-0" style="color:${cardColor}"></i>` : ''}
${isFavorite(c.id) ? `<i class="ph-fill ph-star text-amber-400 text-xs mt-0.5 shrink-0"></i>` : ''}
${todos ? `<i class="ph ${todos.done === todos.total ? 'ph-check-square text-t-accent' : 'ph-squares-four text-t-muted'} text-sm mt-0.5 shrink-0"></i>` : ''}
<h3 class="font-pixel text-xs text-t-bright leading-relaxed line-clamp-2">${esc(c.title)}</h3>
</div>
${todos ? `<div class="mb-2">
<div class="todo-progress">${Array.from({length: todos.total}, (_, i) => `<div class="todo-seg" style="background:${i < todos.done ? cardColor : 'var(--t-border)'}"></div>`).join('')}</div>
</div>` : ''}
${loaded
? (localStorage.getItem('hyprcrd-body-lines') === '0' ? '' : `<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed flex-1" style="-webkit-line-clamp:var(--card-body-lines,5);display:-webkit-box;-webkit-box-orient:vertical;overflow:hidden;">${esc(stripMd(c.body))}</p>`)
: `<div class="flex-1 flex items-center justify-center py-2"><i class="ph ph-spinner card-spinner text-t-dim text-lg"></i></div>`}
${tags.length && localStorage.getItem('hyprcrd-show-tags') !== '0' ? `<div class="card-tags flex flex-wrap gap-1 mt-1.5 card-layer">${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `<span class="tag-chip tag-chip-more">+${tags.length - 5}</span>` : ''}</div>` : ''}
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
${c.due_date ? `<span class="due-pill" style="background:${overdue ? 'var(--t-danger)' : cardColor};"><i class="ph ph-clock-countdown" style="font-size:9px;"></i>${c.due_date}</span>` : `<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>`}
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
${Number(c.read_push) ? '<span class="flex items-center gap-1 text-t-accent" title="On your relay\'s Read list"><i class="ph ph-book-open text-xs"></i></span>' : ''}
</div>
</div>
</div>`;
}
// ── 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 = `<div class="flex flex-col items-center text-center">
<i class="ph ph-circle-notch text-4xl text-t-dim mb-4 block card-spinner"></i>
<p class="font-pixel text-sm text-t-dim">loading…</p></div>`;
} else {
board.innerHTML = `<div class="flex flex-col items-center text-center">
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
}
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 = `<div class="superview-grid">` + 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
? `<span class="superview-tags">${visibleTags.slice(0, 4).map(t => `<span class="superview-tag">#${esc(t)}</span>`).join('')}${visibleTags.length > 4 ? `<span class="superview-tag superview-tag-more">+${visibleTags.length - 4}</span>` : ''}</span>`
: '';
const bucketHtml = c.bucket_name
? `<span class="superview-bucket"><i class="ph ph-folder"></i>${esc(c.bucket_name)}</span>`
: '';
return `<div class="superview-item" data-id="${c.id}">
<span class="superview-dot" style="background:${dotColor}"></span>
${icon ? `<i class="ph ph-${icon} superview-icon" style="color:${iconColor}"></i>` : ''}
<span class="superview-title">${esc(c.title)}</span>
${tagsHtml}
${bucketHtml}
<span class="superview-date">${dateStr}</span>
</div>`;
}).join('') + '</div>';
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 " — <summary>" 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 = `
<button id="group-back" class="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">
<i class="ph ph-arrow-left text-sm"></i><span class="font-pixel text-[10px]">Back</span>
</button>
<i class="ph ph-stack text-t-accent text-sm"></i>
<span class="font-pixel text-[10px] text-t-accent">${esc(parent?.title || 'group')}</span>
<span class="font-pixel text-[9px] text-t-dim">${cards.length} cards</span>`;
$('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 = `<div class="group-fan-grid ${hasMore ? 'has-more' : ''}" style="--fan-cols:${cols}">${visible.map(c => {
const img = firstImage(c.body);
return `<div class="group-fan-card" data-fan-id="${c.id}">
<div class="group-fan-bar" style="background:${c.color}"></div>
${img ? `<div class="group-fan-thumb" style="background-image:url('${img}')"></div>` : ''}
<div class="group-fan-title font-pixel">${esc(c.title)}</div>
</div>`;
}).join('')}</div>`;
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);
});