// ── Board rendering & card interactions ── import { $, esc, api } from './util.js'; import { state, sortedCards, 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'; const board = $('board'); 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`; } $('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(); }); // ── 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') { cards = cards.filter(c => /^\d{4}-\d{2}-\d{2}/.test(c.title)); 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); } } } // 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.innerHTML = `
`; const fc = $('footer-count'); if (fc) fc.textContent = `0 cards · ${state.cards.length} total`; return; } const recent = getRecent(); const inGroup = !!state.activeGroup; board.innerHTML = cards.map(c => { 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; return `
${hasCover ? '
' : ''}
${isGroup ? `
${children.length + 1}
` : ''} ${thumbImg ? `
` : ''}
${isFavorite(c.id) ? `` : ''} ${todos ? `` : ''}

${esc(c.title)}

${todos ? `
${todos.done}/${todos.total}
` : ''}

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

${(c.updated_at || c.created_at || '').substring(0, 10)} ${c.bucket_name ? `${esc(c.bucket_name)}` : ''}
`; }).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 = ` ${esc(parent?.title || 'group')} ${cards.length} cards`; $('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 = `
${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() { board.querySelectorAll('[data-group]').forEach(badge => { badge.addEventListener('click', e => { e.stopPropagation(); state.activeGroup = badge.dataset.group; 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; 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 let filtered; if (state.activeGroup) { const parent = state.cards.find(c => c.id == state.activeGroup); filtered = parent ? sortedCards([parent, ...getChildren(parent.id)]) : []; } else { filtered = sortedCards(state.cards.filter(c => { if (c.parent_id) return false; if (state.activeBucket === 'none') return !c.bucket_id; if (state.activeBucket !== 'all') return c.bucket_id == state.activeBucket; return true; })); } 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 = ''; }); }); }