Files
hyprcrd/public/js/modules/board.js
T
2026-04-08 15:55:43 +02:00

750 lines
35 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 } from './markdown.js';
import { pushReorder, pushCardEdit, pushBatch, snapshotCard } from './undo.js';
import { getCalConfig } from './caldav.js';
import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js';
import { bindCheckboxes } from './detail.js';
const board = $('board');
const boardPreview = $('board-preview');
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 boardSplitOpen = localStorage.getItem('hyprcrd-board-split') === '1';
let boardPreviewCardId = null;
let activeDragEl = null;
let dragGhost = null;
// ── Multi-select ──
const selectedIds = new Set();
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();
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 };
$('select-export')?.addEventListener('click', () => {
if (!selectedIds.size) return;
window.location.href = 'api/export?ids=' + [...selectedIds].join(',');
});
$('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 || !boardSplitOpen) return;
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;
if (boardPreviewBody) {
boardPreviewBody.innerHTML = renderMd(card.body);
addCodeCopyButtons(boardPreviewBody);
formatTodoItems(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');
boardSplitOpen = false;
localStorage.setItem('hyprcrd-board-split', '0');
const btn = $('board-split-toggle');
if (btn) btn.classList.remove('text-t-accent');
board.querySelectorAll('.board-card').forEach(el => el.classList.remove('preview-active'));
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() {
boardSplitOpen = !boardSplitOpen;
localStorage.setItem('hyprcrd-board-split', boardSplitOpen ? '1' : '0');
boardPreview?.classList.toggle('hidden', !boardSplitOpen);
const btn = $('board-split-toggle');
if (btn) btn.classList.toggle('text-t-accent', boardSplitOpen);
if (!boardSplitOpen) {
board.querySelectorAll('.board-card').forEach(el => el.classList.remove('preview-active'));
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 == boardPreviewCardId);
if (card) window._openModal(card);
});
// Init split state
if (boardSplitOpen) {
boardPreview?.classList.remove('hidden');
const btn = $('board-split-toggle');
if (btn) btn.classList.add('text-t-accent');
}
$('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-xs`;
if (btn) btn.classList.toggle('text-t-accent', state.superView);
renderBoard();
});
if (state.superView) {
const btn = $('superview-toggle');
const icon = btn?.querySelector('i');
if (icon) icon.className = 'ph ph-squares-four text-xs';
if (btn) btn.classList.add('text-t-accent');
}
$('hide-termine')?.addEventListener('click', () => {
state.hideTermine = !state.hideTermine;
localStorage.setItem('hyprcrd-hide-termine', state.hideTermine ? '1' : '0');
const btn = $('hide-termine');
const icon = btn?.querySelector('i');
if (icon) icon.className = `ph ${state.hideTermine ? 'ph-calendar-check' : 'ph-calendar-x'} text-xs`;
if (btn) btn.classList.toggle('text-t-accent', state.hideTermine);
renderBoard();
});
// Init toggle state
if (state.hideTermine) {
const btn = $('hide-termine');
const icon = btn?.querySelector('i');
if (icon) icon.className = 'ph ph-calendar-check text-xs';
if (btn) btn.classList.add('text-t-accent');
}
$('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();
});
function renderCardHtml(c, inGroup = false, recent = []) {
const coverImg = leadingImage(c.body);
const thumbImg = coverImg ? null : firstImage(c.body);
const children = getChildren(c.id);
const isGroup = !inGroup && children.length > 0;
const todos = getTodos(c.body);
const todoPct = todos ? Math.round(todos.done / todos.total * 100) : 0;
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;
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}')` : ''};${recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + cardColor : ''}" draggable="true" data-id="${c.id}">
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
<div class="card-bar w-full card-layer" style="background:${cardColor}"></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="flex items-center gap-2 mb-1">
<div class="flex-1 h-1.5 bg-t-border rounded-full overflow-hidden"><div class="h-full rounded-full transition-all" style="width:${todoPct}%;background:${cardColor}"></div></div>
<span class="font-pixel text-[9px] ${todos.done === todos.total ? 'text-t-accent' : 'text-t-dim'}">${todos.done}/${todos.total}</span>
</div></div>` : ''}
<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed line-clamp-5 flex-1">${esc(stripMd(c.body))}</p>
${tags.length ? `<div class="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">
<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() {
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 shows ALL recently touched cards, including group children
const recent = getRecent();
const now = Date.now();
const freshMs = 48 * 60 * 60 * 1000;
cards = state.cards.filter(c => {
if (recent.includes(String(c.id))) return true;
const updated = new Date(c.updated_at || c.created_at || 0).getTime();
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
if (state.hideTermine) {
cards = cards.filter(c => !parseTags(c).includes('termin'));
}
// 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 = '';
board.innerHTML = `<div class="col-span-full text-center py-20">
<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) {
// Compact multi-column list view — override board grid
board.style.display = 'block';
const cols = cards.length <= 20 ? 2 : cards.length <= 50 ? 3 : cards.length <= 100 ? 4 : 5;
board.innerHTML = `<div class="superview-grid" style="column-count:${cols};">` + 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)');
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>
</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 (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.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join('');
bindCardEvents();
// 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`;
}
// ── 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.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');
}
}
// ── 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() {
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();
});
});
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 (boardSplitOpen) { showBoardPreview(id); }
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
dragIds.forEach(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'));
activeDragEl = null;
if (dragGhost) { dragGhost.remove(); dragGhost = null; }
board.querySelectorAll('.card-drop-before,.card-drop-after,.card-drop-group').forEach(x =>
x.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'));
});
el.addEventListener('dragover', e => {
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const edge = rect.width * 0.25, x = e.clientX - rect.left;
el.classList.toggle('card-drop-before', x < edge);
el.classList.toggle('card-drop-after', x > rect.width - edge);
el.classList.toggle('card-drop-group', x >= edge && x <= rect.width - edge);
});
el.addEventListener('dragleave', () => el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group'));
el.addEventListener('drop', e => handleCardDrop(e, el, id));
});
}
// ── Card drop handler ──
async function handleCardDrop(e, el, targetId) {
e.preventDefault();
const wasGroup = el.classList.contains('card-drop-group');
const wasBefore = el.classList.contains('card-drop-before');
el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group');
const dragId = e.dataTransfer.getData('text/plain');
if (!dragId || dragId === 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 = ''; });
});
}