// ── 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 = ``;
} 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 = `
Back
${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);
});