365 lines
16 KiB
JavaScript
365 lines
16 KiB
JavaScript
// ── Board rendering & card interactions ──
|
|
|
|
import { $, esc, api } from './util.js';
|
|
import { state, sortedCards, getChildren, getRecent, 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;
|
|
|
|
// 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 label = $('sort-label');
|
|
const icon = document.querySelector('#sort-toggle i');
|
|
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 {
|
|
cards = state.cards.filter(c => !c.parent_id);
|
|
if (state.activeBucket === 'current') {
|
|
const recent = getRecent();
|
|
const now = Date.now();
|
|
const freshMs = 48 * 60 * 60 * 1000; // 48h
|
|
cards = 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;
|
|
});
|
|
} else if (state.activeBucket === 'none') cards = cards.filter(c => !c.bucket_id);
|
|
else if (state.activeBucket !== 'all') cards = cards.filter(c => c.bucket_id == state.activeBucket);
|
|
}
|
|
cards = sortedCards(cards);
|
|
window._refreshTasks?.();
|
|
|
|
hideFan();
|
|
renderGroupBar(cards);
|
|
|
|
if (!cards.length) {
|
|
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;
|
|
|
|
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 `
|
|
<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:${c.color}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + c.color : ''}" draggable="true" data-id="${c.id}">
|
|
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
|
|
<div class="card-bar w-full card-layer" style="background:${c.color}"></div>
|
|
${isGroup ? `<div class="card-group-badge cursor-pointer hover:scale-110 transition-transform" style="background:${c.color};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 ${thumbImg ? 'pr-16' : ''}">
|
|
<div class="flex items-start gap-2 mb-2">
|
|
${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">${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:${c.color}"></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-3">${esc(stripMd(c.body))}</p>
|
|
<div class="mt-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>`;
|
|
}).join('');
|
|
|
|
bindCardEvents();
|
|
|
|
// 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('[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', () => {
|
|
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';
|
|
el.classList.add('card-dragging');
|
|
activeDragEl = el;
|
|
|
|
// 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 custom ghost
|
|
const rect = el.getBoundingClientRect();
|
|
const ghost = el.cloneNode(true);
|
|
ghost.style.cssText = `position:fixed;z-index:9998;width:${rect.width}px;pointer-events:none;opacity:0.5;transition:opacity 0.12s;`;
|
|
ghost._offsetX = e.clientX - rect.left;
|
|
ghost._offsetY = e.clientY - rect.top;
|
|
ghost.style.left = (e.clientX - ghost._offsetX) + 'px';
|
|
ghost.style.top = (e.clientY - ghost._offsetY) + 'px';
|
|
document.body.appendChild(ghost);
|
|
dragGhost = ghost;
|
|
});
|
|
el.addEventListener('dragend', () => {
|
|
el.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 = ''; });
|
|
});
|
|
}
|