Right-click on a card that's part of a ≥2-card selection now offers
"Join N cards" at the top of the context menu, and the same action
sits next to "Group" in the bottom select-bar.
Title strategy:
* sequential ctrl-click selection → first selected wins
* bulk gesture (rubber-band) → longest title wins
The bulk vs sequential distinction is tracked via a new
`state.selectionWasBulk` flag set in board.js when the rubber-band
becomes active and cleared in clearSelection() and after a successful
join. ctrl-clicks alone don't touch it, so bulk + ctrl-click hybrid
selections stay bulk.
Body format: list of every original title as `## heading` lines, then
each card's body separated by `---`. The winner keeps its color, tags,
bucket, due_date, parent_id; only the body is rewritten. Losers are
deleted via DELETE /api/cards/{id}.
Undo: a single pushUndo entry restores the winner's original body and
recreates every loser via POST + due_date PATCH (since POST doesn't
take due_date). Ctrl+Z reverts the whole join.
Implementation:
* state.js: new selectionWasBulk boolean in state.
* board.js: rubber-band onMove sets state.selectionWasBulk = true on
first activation; clearSelection clears it. New select-join click
handler dynamic-imports joinSelectedCards from context-menu.js to
keep the dependency lazy.
* context-menu.js: joinSelectedCards is now exported. Inserts
data-action="join" into the menu when the right-clicked card is in
a ≥2-card selection. Action handler dispatches to joinSelectedCards
before falling through to the regular if-chain so it can early-return.
* index.php: new #select-join button in #select-bar with ph-arrows-merge
icon, sits between Group and Bucket.
Tests (public/plugins/tests/card-join.test.js, registered in tests.js):
* visibility: shows for ≥2 selected with right-clicked card in the
selection; hidden for solo selection; hidden when right-click target
isn't selected.
* sequential winner: first ctrl-clicked card survives, others deleted.
* merged body lists every title at the top and contains every body.
* selection cleared after a successful join.
* bulk winner: longest title survives regardless of insertion order.
* undo: Ctrl+Z restores winner body + recreates losers.
undo.test.js race-fix:
* New waitForStateCard(id, predicate, timeout=2000) helper polls
state.cards every 30ms until the in-memory state reflects the
expected change. Replaces fragile fixed `sleep(300)` waits between
chained user actions where the second action reads from state.cards
before the first action's _reloadAll has landed.
* Multi-undo color test: between the two color changes, wait for
state.cards to actually contain the pink color before doing the
second rightClick. Without this the second action's contextmenu
handler reads the stale card and pushes the wrong undo snapshot,
collapsing both undo entries onto the original color.
* Due_date undo test: after PATCHing due_date directly via API, wait
for state.cards to reflect it before opening the editor — the
editor's snapshot for the undo entry reads from state.cards.find()
and would otherwise capture due_date=null.
chore: bump version to 0.9.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1269 lines
58 KiB
JavaScript
1269 lines
58 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();
|
|
|
|
// ── 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 deletes selected cards
|
|
document.addEventListener('keydown', async e => {
|
|
if (!selectedIds.size) return;
|
|
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
|
|
e.preventDefault();
|
|
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();
|
|
});
|
|
|
|
// 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');
|
|
const btn = $('board-split-toggle');
|
|
if (btn) btn.classList.remove('text-t-accent');
|
|
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() {
|
|
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 = '';
|
|
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) {
|
|
// 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.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 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();
|
|
}
|
|
|
|
// ── 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);
|
|
});
|