- Context menu tag toggle reads fresh body from state.cards — was overwriting with stale snapshot, dropping existing tags (e.g. #done) - Board empty-state centered with flex (vertical + horizontal) - Plugin settings: batch toggles via Apply button, single reload instead of one reload per toggle - New bucket creation activates it directly on the board Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1358 lines
62 KiB
JavaScript
1358 lines
62 KiB
JavaScript
// ── 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));
|
||
}
|
||
|
||
/** 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>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ── Render ──
|
||
|
||
export function renderBoard() {
|
||
// 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';
|
||
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.
|
||
if (newLocalIds.size) {
|
||
for (const id of newLocalIds) state.recentlyCreatedIds.delete(id);
|
||
pulseCards(newLocalIds, '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);
|
||
});
|