/* ══════════════════════════════════════
hyprcrd — app.js
══════════════════════════════════════ */
// ── Utilities ──
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function escRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function api(path, opts = {}) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
return res.json();
}
// ── Custom dialogs ──
const dialogOverlay = document.getElementById('dialog');
const dialogTitle = document.getElementById('dialog-title');
const dialogMsg = document.getElementById('dialog-msg');
const dialogInput = document.getElementById('dialog-input');
const dialogCancel = document.getElementById('dialog-cancel');
const dialogOk = document.getElementById('dialog-ok');
const dialogClose = document.getElementById('dialog-close');
let dialogResolve = null;
function showDialog({ title, message = '', input = false, placeholder = '', danger = false, okLabel = 'ok', cancelLabel = 'cancel' }) {
return new Promise(resolve => {
dialogResolve = resolve;
dialogTitle.textContent = title;
dialogMsg.textContent = message;
dialogMsg.classList.toggle('hidden', !message);
dialogInput.classList.toggle('hidden', input === false);
dialogInput.value = '';
dialogInput.placeholder = placeholder;
dialogOk.textContent = okLabel;
dialogOk.classList.toggle('border-t-danger', danger);
dialogOk.classList.toggle('text-t-danger', danger);
dialogOk.classList.toggle('border-t-accent', !danger);
dialogOk.classList.toggle('text-t-accent', !danger);
dialogCancel.textContent = cancelLabel;
dialogCancel.classList.toggle('hidden', !cancelLabel);
dialogClose.classList.toggle('hidden', !cancelLabel);
dialogInput.type = input === 'password' ? 'password' : 'text';
dialogOverlay.classList.add('open');
if (input) dialogInput.focus(); else dialogOk.focus();
});
}
function closeDialog(result) {
dialogOverlay.classList.remove('open');
if (dialogResolve) { dialogResolve(result); dialogResolve = null; }
}
dialogOk.addEventListener('click', () => {
const val = dialogInput.classList.contains('hidden') ? true : dialogInput.value;
closeDialog(val);
});
dialogCancel.addEventListener('click', () => { if (!dialogCancel.classList.contains('hidden')) closeDialog(null); });
dialogClose.addEventListener('click', () => { if (!dialogClose.classList.contains('hidden')) closeDialog(null); });
dialogOverlay.addEventListener('click', e => { if (e.target === dialogOverlay && !dialogCancel.classList.contains('hidden')) closeDialog(null); });
dialogOverlay.addEventListener('keydown', e => {
if (e.key === 'Escape' && !dialogCancel.classList.contains('hidden')) closeDialog(null);
if (e.key === 'Enter') {
const val = dialogInput.classList.contains('hidden') ? true : dialogInput.value;
closeDialog(val);
}
});
// ── Markdown ──
marked.use({ breaks: true, gfm: true });
let currentCards = [];
function renderMd(src) {
const linked = (src || '').replace(/\[\[([^\]]+)\]\]/g, (_, title) => {
const card = currentCards.find(c => c.title.toLowerCase() === title.toLowerCase());
if (card) return `${esc(title)}`;
return `${esc(title)}`;
});
return marked.parse(linked);
}
function getTodos(src) {
const s = src || '';
const total = (s.match(/- \[([ x])\]/g) || []).length;
if (!total) return null;
const done = (s.match(/- \[x\]/g) || []).length;
return { total, done };
}
function firstImage(src) {
const s = src || '';
const md = s.match(/!\[[^\]]*\]\(([^)]+)\)/);
if (md) return md[1];
const html = s.match(/
]+src="([^"]+)"/i);
return html ? html[1] : null;
}
function leadingImage(src) {
const s = (src || '').trimStart();
if (!s) return null;
const md = s.match(/^!\[[^\]]*\]\(([^)]+)\)/);
if (md) {
const raw = md[1].trim();
const withoutTitle = raw.split(/\s+/)[0];
const url = withoutTitle.replace(/^<(.+)>$/, '$1');
return url || null;
}
const html = s.match(/^
]+src=(['"])(.*?)\1/i);
return html ? html[2] : null;
}
function stripMd(src) {
return (src || '')
.replace(/
]*>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\[\[([^\]]+)\]\]/g, '$1')
.replace(/^#{1,6}\s+/gm, '')
.replace(/(\*{1,3}|_{1,3}|~~)(.*?)\1/g, '$2')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/`{1,3}[^`]*`{1,3}/g, '')
.replace(/^[-*>]\s+/gm, '')
.replace(/^\d+\.\s+/gm, '')
.replace(/\n{2,}/g, '\n')
.trim();
}
// ── Theme picker ──
const picker = document.getElementById('theme-picker');
const themeList = document.getElementById('theme-list');
const themeToggle = document.getElementById('theme-toggle');
if (themeList) {
for (const [key, t] of Object.entries(themes)) {
const btn = document.createElement('button');
btn.className = 'flex items-center gap-2.5 w-full px-2 py-1.5 text-left text-xs text-t-muted hover:text-t-bright transition-colors rounded';
btn.dataset.themeDot = key;
btn.innerHTML = `
${t.label}`;
btn.onclick = () => applyTheme(key);
themeList.appendChild(btn);
}
}
applyTheme(localStorage.getItem('hyprcrd-theme') || 'slate');
if (picker && themeToggle) {
themeToggle.addEventListener('click', () => picker.classList.toggle('open'));
document.addEventListener('click', e => { if (!picker.contains(e.target)) picker.classList.remove('open'); });
}
// ── Color presets ──
const cardColors = [
{ name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' },
{ name: 'rose', hex: '#fb7185' }, { name: 'amber', hex: '#fbbf24' },
{ name: 'green', hex: '#4ade80' }, { name: 'orange', hex: '#fb923c' },
{ name: 'cyan', hex: '#22d3ee' }, { name: 'pink', hex: '#f472b6' },
{ name: 'lime', hex: '#a3e635' }, { name: 'slate', hex: '#94a3b8' },
];
const presetContainer = document.getElementById('color-presets');
const inpColor = document.getElementById('inp-color');
cardColors.forEach(c => {
const btn = document.createElement('button');
btn.type = 'button';
btn.title = c.name;
btn.dataset.color = c.hex;
btn.className = 'w-5 h-5 rounded-sm border-2 border-transparent transition-all hover:scale-125 color-swatch';
btn.style.background = c.hex;
btn.addEventListener('click', () => selectColor(c.hex));
presetContainer.appendChild(btn);
});
function selectColor(hex) {
inpColor.value = hex;
presetContainer.querySelectorAll('.color-swatch').forEach(el => {
const active = el.dataset.color === hex;
el.classList.toggle('border-t-bright', active);
el.classList.toggle('border-transparent', !active);
el.classList.toggle('scale-125', active);
});
}
// ── Buckets ──
const bucketBar = document.getElementById('bucket-bar');
const inpBucket = document.getElementById('inp-bucket');
const searchBucketFilter = document.getElementById('search-bucket-filter');
const tasksBucketFilter = document.getElementById('tasks-bucket-filter');
let currentBuckets = [];
let activeBucket = 'all';
async function loadBuckets() {
currentBuckets = await api('api/buckets');
renderBucketBar();
populateBucketSelects();
}
function renderBucketBar() {
const tabClass = (active) => `bucket-tab ${active ? 'active' : ''} 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 shrink-0`;
bucketBar.innerHTML = `
${currentBuckets.map(b => `
`).join('')}
`;
bucketBar.querySelectorAll('.bucket-tab').forEach(tab => {
tab.addEventListener('click', () => { activeBucket = tab.dataset.bucket; renderBucketBar(); renderBoard(); });
tab.addEventListener('dragover', e => { e.preventDefault(); tab.classList.add('drag-over'); });
tab.addEventListener('dragleave', () => tab.classList.remove('drag-over'));
tab.addEventListener('drop', async e => {
e.preventDefault();
tab.classList.remove('drag-over');
const cardId = e.dataTransfer.getData('text/plain');
if (!cardId) return;
const bucketId = tab.dataset.bucket === 'none' ? null : (tab.dataset.bucket === 'all' ? undefined : tab.dataset.bucket);
if (bucketId === undefined) return;
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: bucketId } });
await reloadAll();
});
});
document.getElementById('add-bucket-btn').addEventListener('click', async () => {
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
if (!name || !name.trim()) return;
await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
await loadBuckets();
renderBoard();
});
}
function populateBucketSelects() {
inpBucket.innerHTML = `` +
currentBuckets.map(b => ``).join('');
searchBucketFilter.innerHTML = `` +
currentBuckets.map(b => ``).join('');
if (tasksBucketFilter) {
const current = tasksBucketFilter.value;
tasksBucketFilter.innerHTML = `` +
currentBuckets.map(b => ``).join('');
const stillExists = current === '' || current === 'none' || currentBuckets.some(b => String(b.id) === current);
tasksBucketFilter.value = stillExists ? current : '';
}
}
// ── Board ──
const board = document.getElementById('board');
let activeGroup = null; // card id of group parent, or null
let sortMode = localStorage.getItem('hyprcrd-sort') || 'manual'; // 'manual', 'newest', 'oldest', 'alpha'
const sortModes = ['manual', 'newest', 'oldest', 'alpha'];
const sortIcons = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa' };
function updateSortUI() {
document.getElementById('sort-label').textContent = sortMode;
const icon = document.querySelector('#sort-toggle i');
icon.className = `ph ${sortIcons[sortMode]} text-xs`;
}
document.getElementById('sort-toggle').addEventListener('click', () => {
sortMode = sortModes[(sortModes.indexOf(sortMode) + 1) % sortModes.length];
localStorage.setItem('hyprcrd-sort', sortMode);
updateSortUI();
renderBoard();
});
async function loadCards() {
currentCards = await api('api/cards');
updateSortUI();
renderBoard();
}
async function reloadAll() {
currentCards = await api('api/cards');
renderBoard();
}
function sortedCards(cards) {
const arr = [...cards];
switch (sortMode) {
case 'newest': return arr.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
case 'oldest': return arr.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
case 'alpha': return arr.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
default: return arr.sort((a, b) => (a.position || 0) - (b.position || 0));
}
}
function getChildren(parentId) {
return currentCards.filter(c => c.parent_id == parentId);
}
function isGroupParent(id) {
return currentCards.some(c => c.parent_id == id);
}
function renderBoard() {
let cards;
if (activeGroup) {
const parent = currentCards.find(c => c.id == activeGroup);
cards = parent ? [parent, ...getChildren(parent.id)] : [];
} else {
cards = currentCards.filter(c => !c.parent_id);
if (activeBucket === 'none') cards = cards.filter(c => !c.bucket_id);
else if (activeBucket !== 'all') cards = cards.filter(c => c.bucket_id == activeBucket);
}
cards = sortedCards(cards);
refreshTasksView();
// Group breadcrumb bar
const groupBar = document.getElementById('group-bar');
if (activeGroup) {
const parent = currentCards.find(c => c.id == activeGroup);
groupBar.classList.remove('hidden');
groupBar.innerHTML = `
${esc(parent?.title || 'group')}
${cards.length} cards`;
groupBar.querySelector('#group-back').addEventListener('click', () => { activeGroup = null; renderBoard(); });
} else {
groupBar.classList.add('hidden');
}
if (!cards.length) {
board.innerHTML = `
`;
return;
}
const recent = getRecent();
const inGroup = !!activeGroup;
board.innerHTML = cards.map(c => {
const img = firstImage(c.body);
const coverImg = leadingImage(c.body);
const hasCover = !!coverImg;
const thumbImg = hasCover ? null : img;
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;
return `
${hasCover ? '
' : ''}
${isGroup ? `
${children.length + 1}
` : ''}
${thumbImg ? `
` : ''}
${todos ? `` : ''}
${esc(c.title)}
${todos ? `
${todos.done}/${todos.total}
` : ''}
${esc(stripMd(c.body))}
${c.bucket_name ? `
${esc(c.bucket_name)}
` : ''}
`;
}).join('');
// Group badge clicks
board.querySelectorAll('[data-group]').forEach(badge => {
badge.addEventListener('click', e => {
e.stopPropagation();
activeGroup = badge.dataset.group;
renderBoard();
});
});
// Events
board.querySelectorAll('[data-id]').forEach(el => {
const id = el.dataset.id;
// Click = detail (delayed), dblclick = edit
let clickTimer = null;
el.addEventListener('click', () => {
if (clickTimer) return;
clickTimer = setTimeout(() => { clickTimer = null; openDetail(id); }, 200);
});
el.addEventListener('dblclick', e => {
e.preventDefault();
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
const card = currentCards.find(c => c.id == id);
if (card) openModal(card);
});
// Right-click context menu
el.addEventListener('contextmenu', e => {
e.preventDefault();
e.stopPropagation();
const card = currentCards.find(c => c.id == id);
if (card) showContextMenu(e.clientX, e.clientY, card);
});
el.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/plain', id);
e.dataTransfer.effectAllowed = 'move';
el.classList.add('card-dragging');
});
el.addEventListener('dragend', () => {
el.classList.remove('card-dragging');
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 edgeZone = rect.width * 0.25;
const x = e.clientX - rect.left;
const isLeft = x < edgeZone;
const isRight = x > rect.width - edgeZone;
const isCenter = !isLeft && !isRight;
el.classList.toggle('card-drop-before', isLeft);
el.classList.toggle('card-drop-after', isRight);
el.classList.toggle('card-drop-group', isCenter);
});
el.addEventListener('dragleave', () => {
el.classList.remove('card-drop-before', 'card-drop-after', 'card-drop-group');
});
el.addEventListener('drop', async e => {
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 === id) return;
if (wasGroup) {
// Group: set dragged card's parent_id to target
await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: id } });
await reloadAll();
return;
}
// Reorder
let filtered;
if (activeGroup) {
const parent = currentCards.find(c => c.id == activeGroup);
filtered = parent ? sortedCards([parent, ...getChildren(parent.id)]) : [];
} else {
filtered = sortedCards(currentCards.filter(c => {
if (c.parent_id) return false;
if (activeBucket === 'none') return !c.bucket_id;
if (activeBucket !== 'all') return c.bucket_id == activeBucket;
return true;
}));
}
const ids = filtered.map(c => String(c.id)).filter(i => i !== String(dragId));
const targetIdx = ids.indexOf(String(id));
const insertIdx = wasBefore ? targetIdx : targetIdx + 1;
ids.splice(insertIdx, 0, String(dragId));
// Ungroup dragged card if it was a child (when dropping on main board)
const dragCard = currentCards.find(c => c.id == dragId);
if (!activeGroup && dragCard && dragCard.parent_id) {
await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: null } });
}
// FLIP: capture old positions
const oldRects = {};
board.querySelectorAll('[data-id]').forEach(card => {
oldRects[card.dataset.id] = card.getBoundingClientRect();
});
const updates = ids.map((cid, i) => api(`api/cards/${cid}`, { method: 'PATCH', body: { position: i } }));
await Promise.all(updates);
// If in group view and first card changed, swap parent
if (activeGroup && ids[0] !== String(activeGroup)) {
const newParentId = ids[0];
const oldParentId = String(activeGroup);
// New first card becomes the parent: clear its parent_id
await api(`api/cards/${newParentId}`, { method: 'PATCH', body: { parent_id: null } });
// Old parent becomes a child of the new parent
await api(`api/cards/${oldParentId}`, { method: 'PATCH', body: { parent_id: newParentId } });
// All other children point to new parent
for (const cid of ids.slice(1)) {
if (cid !== oldParentId) {
await api(`api/cards/${cid}`, { method: 'PATCH', body: { parent_id: newParentId } });
}
}
activeGroup = newParentId;
}
sortMode = 'manual';
localStorage.setItem('hyprcrd-sort', 'manual');
updateSortUI();
await reloadAll();
// FLIP: animate
board.querySelectorAll('[data-id]').forEach(card => {
const oldRect = oldRects[card.dataset.id];
if (!oldRect) return;
const newRect = card.getBoundingClientRect();
const dx = oldRect.left - newRect.left;
const dy = oldRect.top - newRect.top;
if (dx === 0 && dy === 0) return;
card.style.transition = 'none';
card.style.transform = `translate(${dx}px, ${dy}px)`;
requestAnimationFrame(() => {
card.style.transition = 'transform 0.25s ease';
card.style.transform = '';
});
});
});
});
}
// ── Detail view ──
const detail = document.getElementById('detail');
const detailScroll = document.getElementById('detail-scroll');
const detailTitle = document.getElementById('detail-title');
const detailBody = document.getElementById('detail-body');
const detailMeta = document.getElementById('detail-meta');
let viewingCard = null;
let openedFromSearch = false;
const DETAIL_ZOOM_KEY = 'hyprcrd-detail-zoom';
const DETAIL_ZOOM_STEP = 0.08;
const DETAIL_ZOOM_MIN = 0.75;
const DETAIL_ZOOM_MAX = 2.2;
let detailZoom = Number.parseFloat(localStorage.getItem(DETAIL_ZOOM_KEY) || '1');
if (!Number.isFinite(detailZoom)) detailZoom = 1;
function setDetailZoom(nextZoom) {
detailZoom = Math.min(DETAIL_ZOOM_MAX, Math.max(DETAIL_ZOOM_MIN, nextZoom));
detailZoom = Math.round(detailZoom * 100) / 100;
localStorage.setItem(DETAIL_ZOOM_KEY, String(detailZoom));
applyDetailZoom();
}
function applyDetailZoom() {
if (!detailTitle || !detailBody || !detailMeta) return;
detailTitle.style.fontSize = `${(1.125 * detailZoom).toFixed(3)}rem`;
detailBody.style.fontSize = `${(0.875 * detailZoom).toFixed(3)}rem`;
detailMeta.style.fontSize = `${Math.max(0.625, 0.6875 * detailZoom).toFixed(3)}rem`;
detailTitle.style.lineHeight = `${Math.max(1.35, 1.55 + (detailZoom - 1) * 0.3).toFixed(3)}`;
detailBody.style.lineHeight = `${Math.max(1.5, 1.7 + (detailZoom - 1) * 0.35).toFixed(3)}`;
detailMeta.style.lineHeight = `${Math.max(1.2, 1.35 + (detailZoom - 1) * 0.25).toFixed(3)}`;
}
applyDetailZoom();
// Recently opened tracking
function getRecent() {
try { return JSON.parse(localStorage.getItem('hyprcrd-recent') || '[]'); } catch { return []; }
}
function markRecent(id) {
const r = getRecent().filter(i => i != id);
r.unshift(String(id));
localStorage.setItem('hyprcrd-recent', JSON.stringify(r.slice(0, 10)));
}
function isRecent(id) { return getRecent().includes(String(id)); }
// Resolve the group context: if card is a child, find parent + siblings
function getGroupCards(card) {
if (card.parent_id) {
const parent = currentCards.find(c => c.id == card.parent_id);
if (parent) return [parent, ...getChildren(parent.id)];
}
const children = getChildren(card.id);
if (children.length) return [card, ...children];
return null; // not a group
}
function openDetail(id) {
const card = currentCards.find(c => c.id == id);
if (!card) return;
markRecent(id);
const el = board.querySelector(`[data-id="${id}"]`);
if (el) el.style.borderBottom = `3px solid ${card.color}`;
if (!detail.classList.contains('open')) {
openedFromSearch = searchOverlay.classList.contains('open');
}
viewingCard = card;
// Group sidebar
const sidebar = document.getElementById('detail-sidebar');
const sidebarList = document.getElementById('detail-sidebar-list');
const groupCards = getGroupCards(card);
if (groupCards && groupCards.length > 1) {
sidebar.classList.remove('hidden');
document.getElementById('detail-sidebar-count').textContent = groupCards.length;
sidebarList.innerHTML = groupCards.map(gc => {
const active = gc.id == card.id;
const img = firstImage(gc.body);
return `
`;
}).join('');
sidebarList.querySelectorAll('.sidebar-card').forEach(sc => {
sc.addEventListener('click', () => showDetailCard(sc.dataset.sid));
});
} else {
sidebar.classList.add('hidden');
}
showDetailContent(card);
detail.classList.add('open');
}
function showDetailCard(id) {
const card = currentCards.find(c => c.id == id);
if (!card) return;
viewingCard = card;
markRecent(id);
// Update sidebar active state
document.querySelectorAll('#detail-sidebar-list .sidebar-card').forEach(sc => {
const active = sc.dataset.sid == id;
sc.classList.toggle('border-t-accent', active);
sc.classList.toggle('bg-t-raised', active);
sc.classList.toggle('border-transparent', !active);
sc.querySelector('span:last-of-type')?.classList.toggle('text-t-bright', active);
sc.querySelector('span:last-of-type')?.classList.toggle('text-t-muted', !active);
});
showDetailContent(card);
}
function showDetailContent(card) {
document.getElementById('detail-color').style.background = card.color;
detailTitle.textContent = card.title;
detailBody.innerHTML = renderMd(card.body);
// Make checkboxes interactive
detailBody.querySelectorAll('input[type="checkbox"]').forEach((cb, idx) => {
cb.disabled = false;
cb.addEventListener('change', async () => {
// Toggle the nth checkbox in markdown source
let body = card.body;
let n = 0;
body = body.replace(/- \[([ x])\]/g, (match, state) => {
if (n++ === idx) return `- [${state === 'x' ? ' ' : 'x'}]`;
return match;
});
card.body = body;
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
// Update card in local state
const c = currentCards.find(x => x.id == card.id);
if (c) c.body = body;
renderBoard();
});
});
const meta = '> created: ' + card.created_at + ' | modified: ' + card.updated_at +
(card.bucket_name ? ' | bucket: ' + card.bucket_name : '');
detailMeta.textContent = meta;
applyDetailZoom();
// Backlinks
const pattern = `[[${card.title}]]`.toLowerCase();
const backlinks = currentCards.filter(c => c.id != card.id && (c.body || '').toLowerCase().includes(pattern));
const blContainer = document.getElementById('detail-backlinks');
const blList = document.getElementById('detail-backlinks-list');
if (backlinks.length) {
blContainer.classList.remove('hidden');
blList.innerHTML = backlinks.map(b => `
`).join('');
} else {
blContainer.classList.add('hidden');
}
}
function closeDetail() {
detail.classList.remove('open');
viewingCard = null;
if (openedFromSearch) { openedFromSearch = false; searchInput.focus(); }
}
detail.addEventListener('click', e => { if (e.target === detail) closeDetail(); });
if (detailScroll) {
detailScroll.addEventListener('wheel', e => {
if (!detail.classList.contains('open')) return;
if (!e.shiftKey) return; // zoom only with Shift+wheel
if (Math.abs(e.deltaY) < Math.abs(e.deltaX)) return;
e.preventDefault();
const step = e.deltaY < 0 ? DETAIL_ZOOM_STEP : -DETAIL_ZOOM_STEP;
setDetailZoom(detailZoom + step);
}, { passive: false });
}
async function ungroupCard(id) {
await api(`api/cards/${id}`, { method: 'PATCH', body: { parent_id: null } });
await reloadAll();
if (viewingCard) {
// If we ungrouped the card we're viewing, or the parent, re-open
if (viewingCard.id == id) {
openDetail(id);
} else {
openDetail(viewingCard.id);
}
}
}
document.getElementById('detail-edit').addEventListener('click', () => {
if (!viewingCard) return;
const card = viewingCard;
openedFromSearch = false;
closeDetail();
closeSearch();
openModal(card);
});
document.getElementById('detail-delete').addEventListener('click', async () => {
if (!viewingCard) return;
const ok = await showDialog({ title: 'delete_card', message: `"${viewingCard.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
if (!ok) return;
await api(`api/cards/${viewingCard.id}`, { method: 'DELETE' });
closeDetail();
await reloadAll();
});
document.getElementById('detail-close').addEventListener('click', closeDetail);
// ── Editor ──
const modal = document.getElementById('modal');
const editorBody = document.getElementById('editor-body');
const previewPane = document.getElementById('editor-preview');
const previewContent = document.getElementById('preview-content');
let editingId = null;
let previewVisible = false;
function openModal(card = null) {
editingId = card ? card.id : null;
document.getElementById('modal-title').textContent = card ? 'edit_card' : 'new_card';
document.getElementById('inp-title').value = card ? card.title : '';
editorBody.value = card ? card.body : '';
inpBucket.value = card && card.bucket_id ? card.bucket_id
: (activeBucket !== 'all' && activeBucket !== 'none' ? activeBucket : '');
selectColor(card ? card.color : '#38bdf8');
previewVisible = true;
previewPane.classList.remove('hidden');
document.getElementById('toggle-preview').classList.add('text-t-accent');
updatePreview();
modal.classList.add('open');
document.getElementById('inp-title').focus();
}
function closeModal() { modal.classList.remove('open'); editingId = null; }
function updatePreview() {
if (previewVisible) previewContent.innerHTML = renderMd(editorBody.value);
}
async function saveCard() {
const data = {
title: document.getElementById('inp-title').value,
body: editorBody.value,
color: inpColor.value,
bucket_id: inpBucket.value || null,
};
if (!data.title.trim()) return;
if (editingId) {
await api(`api/cards/${editingId}`, { method: 'PATCH', body: data });
} else {
// New cards get position -1 so they sort before everything at position 0+
data.position = -1;
await api('api/cards', { method: 'POST', body: data });
}
closeModal();
await reloadAll();
}
function insertMd(before, after = '', placeholder = '') {
editorBody.focus();
const start = editorBody.selectionStart, end = editorBody.selectionEnd;
const sel = editorBody.value.substring(start, end) || placeholder;
editorBody.setRangeText(before + sel + after, start, end, 'select');
editorBody.selectionStart = start + before.length;
editorBody.selectionEnd = start + before.length + sel.length;
updatePreview();
}
document.getElementById('md-toolbar').addEventListener('click', e => {
const btn = e.target.closest('[data-md]');
if (!btn) return;
const actions = {
heading: () => insertMd('## ', '', 'Heading'),
bold: () => insertMd('**', '**', 'bold'),
italic: () => insertMd('*', '*', 'italic'),
strikethrough: () => insertMd('~~', '~~', 'text'),
code: () => insertMd('`', '`', 'code'),
codeblock: () => insertMd('```\n', '\n```', 'code'),
ul: () => insertMd('- ', '', 'item'),
ol: () => insertMd('1. ', '', 'item'),
quote: () => insertMd('> ', '', 'quote'),
todo: () => insertMd('- [ ] ', '', 'task'),
link: () => insertMd('[', '](url)', 'text'),
cardlink: () => { insertMd('[[', ']]', ''); showLinkAc(''); },
hr: () => insertMd('\n---\n'),
};
if (actions[btn.dataset.md]) actions[btn.dataset.md]();
});
document.getElementById('toggle-preview').addEventListener('click', () => {
previewVisible = !previewVisible;
previewPane.classList.toggle('hidden', !previewVisible);
document.getElementById('toggle-preview').classList.toggle('text-t-accent', previewVisible);
updatePreview();
});
document.getElementById('add-btn').addEventListener('click', () => openModal());
document.getElementById('modal-cancel').addEventListener('click', closeModal);
document.getElementById('modal-save').addEventListener('click', saveCard);
// ── Card link autocomplete ──
const linkAc = document.getElementById('link-ac');
let linkAcVisible = false, linkAcItems = [], linkAcActive = 0;
function getLinkQuery() {
const before = editorBody.value.substring(0, editorBody.selectionStart);
const idx = before.lastIndexOf('[[');
if (idx === -1) return null;
const between = before.substring(idx + 2);
if (between.includes(']]')) return null;
return { start: idx, query: between };
}
function showLinkAc(query) {
const q = query.toLowerCase();
linkAcItems = currentCards
.filter(c => c.id != editingId)
.map(c => {
const t = c.title.toLowerCase();
let score = 0;
if (t.startsWith(q)) score = 100;
else if (t.includes(q)) score = 50;
else if (fuzzyMatch(q, t) !== null) score = 10;
else if (!q) score = 1;
else return null;
return { card: c, score };
})
.filter(Boolean).sort((a, b) => b.score - a.score).slice(0, 8).map(s => s.card);
if (!linkAcItems.length) { hideLinkAc(); return; }
linkAcActive = 0;
linkAc.innerHTML = linkAcItems.map((c, i) => `
${esc(c.title)}
${c.bucket_name ? `${esc(c.bucket_name)}` : ''}
`).join('');
linkAc.querySelectorAll('.ac-item').forEach(el => {
el.addEventListener('mousedown', e => { e.preventDefault(); completeLinkAc(parseInt(el.dataset.idx)); });
});
// Position
const rect = editorBody.getBoundingClientRect();
const lines = editorBody.value.substring(0, editorBody.selectionStart).split('\n');
let top = rect.top + (lines.length - 1) * 22 - editorBody.scrollTop + 26;
let left = rect.left + 40;
top = Math.min(top, window.innerHeight - 200);
left = Math.min(left, window.innerWidth - 380);
linkAc.style.top = top + 'px';
linkAc.style.left = left + 'px';
linkAc.classList.remove('hidden');
linkAcVisible = true;
}
function hideLinkAc() { linkAc.classList.add('hidden'); linkAcVisible = false; }
function completeLinkAc(idx) {
const card = linkAcItems[idx];
if (!card) return;
const info = getLinkQuery();
if (!info) { hideLinkAc(); return; }
const before = editorBody.value.substring(0, info.start);
let after = editorBody.value.substring(editorBody.selectionStart);
if (after.startsWith(']]')) after = after.substring(2);
else if (after.startsWith(']')) after = after.substring(1);
const insert = `[[${card.title}]]`;
editorBody.value = before + insert + after;
editorBody.selectionStart = editorBody.selectionEnd = info.start + insert.length;
hideLinkAc();
updatePreview();
editorBody.focus();
}
editorBody.addEventListener('input', () => {
const info = getLinkQuery();
if (info) showLinkAc(info.query); else hideLinkAc();
updatePreview();
});
editorBody.addEventListener('keydown', e => {
if (linkAcVisible) {
if (e.key === 'ArrowDown') { e.preventDefault(); linkAcActive = Math.min(linkAcActive + 1, linkAcItems.length - 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); linkAcActive = Math.max(linkAcActive - 1, 0); }
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); completeLinkAc(linkAcActive); return; }
else if (e.key === 'Escape') { e.preventDefault(); hideLinkAc(); return; }
linkAc.querySelectorAll('.ac-item').forEach((el, i) => el.classList.toggle('active', i === linkAcActive));
return;
}
if (e.key === 'Tab') { e.preventDefault(); insertMd(' '); }
});
editorBody.addEventListener('blur', () => setTimeout(hideLinkAc, 150));
// ── Context menu ──
const ctxMenu = document.getElementById('ctx-menu');
const ctxItems = document.getElementById('ctx-items');
const ctxColors = document.getElementById('ctx-colors');
const ctxBuckets = document.getElementById('ctx-buckets');
let ctxCard = null;
function hideContextMenu() {
ctxMenu.classList.add('hidden');
ctxCard = null;
}
document.addEventListener('click', () => hideContextMenu());
document.addEventListener('contextmenu', e => {
if (!ctxMenu.contains(e.target)) hideContextMenu();
});
function showContextMenu(x, y, card) {
ctxCard = card;
// Group context
const children = getChildren(card.id);
const isGroupParent = children.length > 0;
const isChild = !!card.parent_id;
// Main items
ctxItems.innerHTML = `
${isGroupParent ? `
` : ''}
${isChild ? `
` : ''}
`;
// Color sub-panel
const ctxColorGrid = document.getElementById('ctx-color-grid');
ctxColorGrid.innerHTML = cardColors.map(c =>
``
).join('');
ctxColors.classList.add('hidden');
// Members sub-panel
const ctxMembers = document.getElementById('ctx-members');
if (isGroupParent) {
const allGroup = [card, ...children];
ctxMembers.innerHTML = allGroup.map(gc => `
`).join('');
}
ctxMembers.classList.add('hidden');
// Bucket sub-panel
ctxBuckets.innerHTML = `
${currentBuckets.map(b => `
`).join('')}`;
ctxBuckets.classList.add('hidden');
// Position
const mw = 180;
const mh = 300;
ctxMenu.style.left = Math.min(x, window.innerWidth - mw - 8) + 'px';
ctxMenu.style.top = Math.min(y, window.innerHeight - mh - 8) + 'px';
ctxMenu.classList.remove('hidden');
// Action handlers
ctxItems.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const action = btn.dataset.action;
if (action === 'open') { hideContextMenu(); openDetail(card.id); }
else if (action === 'edit') { hideContextMenu(); openModal(card); }
else if (action === 'duplicate') {
hideContextMenu();
await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
await reloadAll();
}
else if (action === 'color') {
ctxColors.classList.toggle('hidden');
ctxBuckets.classList.add('hidden');
ctxMembers.classList.add('hidden');
}
else if (action === 'bucket') {
ctxBuckets.classList.toggle('hidden');
ctxColors.classList.add('hidden');
ctxMembers.classList.add('hidden');
}
else if (action === 'group-members') {
ctxMembers.classList.toggle('hidden');
ctxColors.classList.add('hidden');
ctxBuckets.classList.add('hidden');
}
else if (action === 'expand-group') {
hideContextMenu();
activeGroup = card.id;
renderBoard();
}
else if (action === 'go-to-group') {
hideContextMenu();
const parent = currentCards.find(c => c.id == card.parent_id);
if (parent) { activeGroup = parent.id; renderBoard(); }
}
else if (action === 'dissolve') {
hideContextMenu();
const ok = await showDialog({ title: 'dissolve_group', message: `Gruppe "${card.title}" aufloesen? Alle Karten werden einzeln.`, danger: true, okLabel: 'dissolve' });
if (!ok) return;
const ch = getChildren(card.id);
await Promise.all(ch.map(c => api(`api/cards/${c.id}`, { method: 'PATCH', body: { parent_id: null } })));
await reloadAll();
}
else if (action === 'ungroup') {
hideContextMenu();
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { parent_id: null } });
await reloadAll();
}
else if (action === 'delete') {
hideContextMenu();
const ok = await showDialog({ title: 'delete_card', message: `"${card.title}" wirklich loeschen?`, danger: true, okLabel: 'delete' });
if (!ok) return;
await api(`api/cards/${card.id}`, { method: 'DELETE' });
await reloadAll();
}
});
});
// Color clicks
ctxColorGrid.querySelectorAll('[data-ctx-color]').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
hideContextMenu();
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { color: btn.dataset.ctxColor } });
await reloadAll();
});
});
// Bucket clicks
ctxBuckets.querySelectorAll('[data-ctx-bucket]').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
hideContextMenu();
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { bucket_id: btn.dataset.ctxBucket || null } });
await reloadAll();
});
});
// Member clicks
ctxMembers.querySelectorAll('[data-ctx-member]').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
if (e.target.closest('[data-ctx-kick]')) return; // handled below
hideContextMenu();
openDetail(btn.dataset.ctxMember);
});
});
ctxMembers.querySelectorAll('[data-ctx-kick]').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
hideContextMenu();
await api(`api/cards/${btn.dataset.ctxKick}`, { method: 'PATCH', body: { parent_id: null } });
await reloadAll();
});
});
}
// ── Image resize in preview ──
document.addEventListener('mousedown', e => {
const img = e.target.closest('.prose img');
if (!img) return;
e.preventDefault();
img.classList.add('resizing');
const startX = e.clientX;
const startW = img.offsetWidth;
function onMove(ev) {
const delta = ev.clientX - startX;
const newW = Math.max(50, startW + delta);
img.style.width = newW + 'px';
img.style.maxWidth = 'none';
}
function onUp() {
img.classList.remove('resizing');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
// Persist width back into editor markdown if in editor preview
const width = Math.round(img.offsetWidth);
const src = img.getAttribute('src');
if (src && modal.classList.contains('open')) {
const val = editorBody.value;
// Match  or and set width via HTML img tag
const mdPattern = new RegExp('!\\[([^\\]]*)\\]\\(' + escRe(src) + '\\)');
const match = val.match(mdPattern);
if (match) {
const replacement = `
`;
editorBody.value = val.replace(match[0], replacement);
updatePreview();
} else {
// Already an
tag — update width
const imgTagRe = new RegExp('(
]*src="' + escRe(src) + '"[^>]*?)(?:\\swidth="[^"]*")?([^>]*>)');
const imgMatch = val.match(imgTagRe);
if (imgMatch) {
editorBody.value = val.replace(imgMatch[0], imgMatch[1] + ` width="${width}"` + imgMatch[2]);
updatePreview();
}
}
}
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
// ── Clipboard image paste ──
editorBody.addEventListener('paste', async e => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (!item.type.startsWith('image/')) continue;
e.preventDefault();
const file = item.getAsFile();
if (!file) return;
// Insert placeholder while uploading
const placeholder = '![uploading...]()';
const pos = editorBody.selectionStart;
editorBody.setRangeText(placeholder, pos, editorBody.selectionEnd, 'end');
updatePreview();
// Upload
const form = new FormData();
form.append('file', file, 'paste.' + (file.type.split('/')[1] || 'png'));
try {
const res = await fetch('api/upload', { method: 'POST', body: form });
const text = await res.text();
console.log('Upload response:', res.status, text);
const data = JSON.parse(text);
if (data.error) throw new Error(data.error);
if (data.url) {
const md = ``;
const current = editorBody.value;
const idx = current.indexOf(placeholder);
if (idx !== -1) {
editorBody.value = current.substring(0, idx) + md + current.substring(idx + placeholder.length);
editorBody.selectionStart = editorBody.selectionEnd = idx + md.length;
}
}
} catch (err) {
console.error('Upload failed:', err);
// Replace placeholder with error hint
const current = editorBody.value;
const idx = current.indexOf(placeholder);
if (idx !== -1) {
editorBody.value = current.substring(0, idx) + current.substring(idx + placeholder.length);
editorBody.selectionStart = editorBody.selectionEnd = idx;
}
}
updatePreview();
return;
}
});
// ── Search ──
const searchOverlay = document.getElementById('search');
const searchInput = document.getElementById('search-input');
const searchResults = document.getElementById('search-results');
const searchFooter = document.getElementById('search-footer');
const searchPreviewEmpty = document.getElementById('search-preview-empty');
const searchPreviewContent = document.getElementById('search-preview-content');
const searchCloseBtn = document.getElementById('search-close');
let searchActive = -1, searchHits = [];
function openSearch() {
closeTasks();
searchInput.value = '';
searchResults.innerHTML = '';
searchFooter.classList.add('hidden');
searchActive = -1;
searchOverlay.classList.add('open');
searchInput.focus();
clearSearchPreview();
renderSearchHome();
}
function closeSearch() { searchOverlay.classList.remove('open'); }
function clearSearchPreview() {
searchPreviewEmpty.classList.remove('hidden');
searchPreviewContent.classList.add('hidden');
}
function updateSearchPreview() {
const card = searchHits[searchActive];
if (!card) { clearSearchPreview(); return; }
searchPreviewEmpty.classList.add('hidden');
searchPreviewContent.classList.remove('hidden');
document.getElementById('search-preview-color').style.background = card.color;
const q = searchInput.value.trim();
const words = q ? q.toLowerCase().split(/\s+/).filter(Boolean) : [];
document.getElementById('search-preview-title').innerHTML = words.length
? highlight(esc(card.title), words)
: esc(card.title);
let bodyHtml = renderMd(card.body);
if (words.length) {
// Highlight search words in rendered HTML (skip inside tags)
for (const w of words) {
const re = new RegExp('(>)([^<]*?)(' + escRe(w) + ')', 'gi');
bodyHtml = bodyHtml.replace(re, '$1$2$3');
}
}
document.getElementById('search-preview-body').innerHTML = bodyHtml;
document.getElementById('search-preview-meta').textContent =
'> created: ' + card.created_at + ' | modified: ' + card.updated_at +
(card.bucket_name ? ' | bucket: ' + card.bucket_name : '');
}
function getSearchPool() {
const bv = searchBucketFilter.value;
if (!bv) return currentCards;
if (bv === 'none') return currentCards.filter(c => !c.bucket_id);
return currentCards.filter(c => c.bucket_id == bv);
}
function renderSearchHome() {
const recent = [...getSearchPool()].sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || '')).slice(0, 6);
if (!recent.length) {
searchResults.innerHTML = `no cards yet
`;
return;
}
searchResults.innerHTML = `recent
${recent.map((c, i) => searchHitHtml(c, i, null)).join('')}`;
searchHits = recent;
searchActive = recent.length ? 0 : -1;
searchFooter.classList.remove('hidden');
bindSearchHits();
updateSearchActive();
updateSearchPreview();
}
// ── Fuzzy search engine ──
function fuzzyMatch(needle, haystack) {
if (needle.length < 3) return null; // minimum 3 chars for fuzzy
let ni = 0, hi = 0, score = 0, streak = 0;
while (ni < needle.length && hi < haystack.length) {
if (needle[ni] === haystack[hi]) {
streak++; score += streak * 2;
if (hi === 0 || /[\s\-_./]/.test(haystack[hi - 1])) score += 10;
if (hi < 5) score += 5 - hi;
ni++;
} else { streak = 0; }
hi++;
}
if (ni < needle.length) return null;
score -= (haystack.length - needle.length) * 0.3;
return score;
}
function countSubstring(str, sub) {
let count = 0, pos = 0;
while ((pos = str.indexOf(sub, pos)) !== -1) { count++; pos += sub.length; }
return count;
}
function searchCards(query) {
const q = query.toLowerCase().trim();
if (!q) { renderSearchHome(); return; }
const words = q.split(/\s+/).filter(Boolean);
const pool = getSearchPool();
const scored = pool.map(c => {
const title = (c.title || '').toLowerCase();
const body = stripMd(c.body || '').toLowerCase();
let score = 0, matched = false;
if (title.includes(q)) { score += 150; matched = true; }
if (body.includes(q)) { score += 40 + countSubstring(body, q) * 10; matched = true; }
let allWordsMatch = true;
for (const w of words) {
const inTitle = title.includes(w), inBody = body.includes(w);
if (inTitle) { score += 30; if (new RegExp('(^|[\\s\\-_./])' + escRe(w)).test(title)) score += 20; if (title.startsWith(w)) score += 15; score += countSubstring(title, w) * 5; }
if (inBody) { score += 8 + countSubstring(body, w) * 3; }
if (!inTitle && !inBody) allWordsMatch = false;
}
if (allWordsMatch) { score += 50; matched = true; }
const tf = fuzzyMatch(q, title), bf = fuzzyMatch(q, body);
if (tf !== null) { score += 20 + tf * 2; matched = true; }
if (bf !== null) { score += 5 + bf * 0.5; matched = true; }
if (!matched) {
let fh = 0;
for (const w of words) { if (fuzzyMatch(w, title) !== null) { score += 10; fh++; } if (fuzzyMatch(w, body) !== null) { score += 3; fh++; } }
if (fh > 0) matched = true;
}
if (!matched) return null;
score -= title.length * 0.1;
return { card: c, score };
}).filter(Boolean).sort((a, b) => b.score - a.score);
searchHits = scored.map(s => s.card);
searchActive = scored.length ? 0 : -1;
if (!scored.length) {
searchResults.innerHTML = `no results for "${esc(query)}"
`;
searchFooter.classList.add('hidden');
clearSearchPreview();
return;
}
searchResults.innerHTML = `${scored.length} result${scored.length > 1 ? 's' : ''}
${scored.map((s, i) => searchHitHtml(s.card, i, words)).join('')}`;
searchFooter.classList.remove('hidden');
bindSearchHits();
updateSearchActive();
updateSearchPreview();
}
function searchHitHtml(card, idx, words) {
const title = words ? highlight(esc(card.title), words) : esc(card.title);
const plain = stripMd(card.body || '');
let snippet = '';
if (words && plain) {
const lower = plain.toLowerCase();
const fullQ = words.join(' ');
const winSize = 160;
// Score each position: exact phrase > multi-word density > single word
let best = 0, bestScore = -1;
for (let i = 0; i < lower.length; i += 10) {
const win = lower.substring(i, i + winSize);
let ws = 0;
// Exact full query in window
if (win.includes(fullQ)) ws += 50;
// Per-word: count occurrences, weight by proximity to each other
let firstHit = -1, lastHit = -1;
for (const w of words) {
let p = 0, hits = 0;
while ((p = win.indexOf(w, p)) !== -1) {
hits++;
if (firstHit === -1 || p < firstHit) firstHit = p;
if (p > lastHit) lastHit = p + w.length;
p += w.length;
}
ws += hits * 8;
}
// Proximity bonus: words close together score higher
if (firstHit !== -1 && lastHit !== -1) {
const span = lastHit - firstHit;
if (span > 0) ws += Math.max(0, 30 - span / 2);
}
if (ws > bestScore) { bestScore = ws; best = i; }
}
// Center the snippet around the best match
const center = Math.min(best + winSize / 2, lower.length);
const start = Math.max(0, Math.floor(center - winSize / 2));
const end = Math.min(plain.length, start + winSize);
snippet = (start > 0 ? '...' : '') + plain.substring(start, end).trim() + (end < plain.length ? '...' : '');
snippet = highlight(esc(snippet), words);
} else {
snippet = esc(plain.substring(0, 120)) + (plain.length > 120 ? '...' : '');
}
return ``;
}
function highlight(html, words) {
let result = html;
for (const w of words) result = result.replace(new RegExp('(' + escRe(w) + ')', 'gi'), '$1');
if (!result.includes('')) {
const full = words.join('');
let fi = 0, out = '';
for (let i = 0; i < html.length && fi < full.length; i++) {
if (html[i].toLowerCase() === full[fi]) { out += '' + html[i] + ''; fi++; } else { out += html[i]; }
}
out += html.substring(out.replace(/<\/?mark>/g, '').length);
result = out;
}
return result;
}
function bindSearchHits() {
searchResults.querySelectorAll('.search-hit').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.idx);
if (searchActive === idx && searchHits[idx]) openDetail(searchHits[idx].id);
else { searchActive = idx; updateSearchActive(); }
});
el.addEventListener('dblclick', () => { const idx = parseInt(el.dataset.idx); if (searchHits[idx]) openDetail(searchHits[idx].id); });
});
}
function updateSearchActive() {
searchResults.querySelectorAll('.search-hit').forEach((el, i) => {
el.classList.toggle('active', i === searchActive);
if (i === searchActive) el.scrollIntoView({ block: 'nearest' });
});
updateSearchPreview();
}
searchInput.addEventListener('input', () => searchCards(searchInput.value));
searchBucketFilter.addEventListener('change', () => searchCards(searchInput.value));
searchInput.addEventListener('keydown', e => {
const hits = searchResults.querySelectorAll('.search-hit');
if (e.key === 'ArrowDown') { e.preventDefault(); searchActive = Math.min(searchActive + 1, hits.length - 1); updateSearchActive(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); searchActive = Math.max(searchActive - 1, 0); updateSearchActive(); }
else if (e.key === 'Enter' && searchActive >= 0 && searchHits[searchActive]) { e.preventDefault(); openDetail(searchHits[searchActive].id); }
});
searchOverlay.addEventListener('click', e => { if (e.target === searchOverlay) closeSearch(); });
document.getElementById('search-btn').addEventListener('click', openSearch);
if (searchCloseBtn) searchCloseBtn.addEventListener('click', closeSearch);
// ── Global tasks ──
const tasksOverlay = document.getElementById('tasks');
const tasksBtn = document.getElementById('tasks-btn');
const tasksCloseBtn = document.getElementById('tasks-close');
const tasksStatusFilter = document.getElementById('tasks-status-filter');
const tasksList = document.getElementById('tasks-list');
const tasksSummary = document.getElementById('tasks-summary');
const tasksOpenCount = document.getElementById('tasks-open-count');
function collectTasks() {
const all = [];
for (const card of currentCards) {
const body = card.body || '';
const taskRe = /- \[([ xX])\][ \t]*([^\n\r]*)/g;
let m;
let idx = 0;
while ((m = taskRe.exec(body)) !== null) {
all.push({
cardId: card.id,
cardTitle: card.title || 'untitled',
cardColor: card.color || '#94a3b8',
bucketId: card.bucket_id,
bucketName: card.bucket_name || '',
done: m[1].toLowerCase() === 'x',
text: (m[2] || '').trim() || '(empty task)',
todoIndex: idx,
});
idx++;
}
}
return all;
}
function filterTasks(tasks) {
const status = tasksStatusFilter?.value || 'open';
const bucket = tasksBucketFilter?.value || '';
return tasks.filter(t => {
if (status === 'open' && t.done) return false;
if (status === 'done' && !t.done) return false;
if (bucket === 'none') return !t.bucketId;
if (bucket !== '' && t.bucketId != bucket) return false;
return true;
});
}
function refreshTasksView() {
if (!tasksOpenCount) return;
const tasks = collectTasks();
const openCount = tasks.filter(t => !t.done).length;
tasksOpenCount.textContent = String(openCount);
if (!tasksOverlay || !tasksOverlay.classList.contains('open') || !tasksList || !tasksSummary) return;
const filtered = filterTasks(tasks);
const doneCount = tasks.length - openCount;
tasksSummary.textContent = `open: ${openCount} | done: ${doneCount} | showing: ${filtered.length}`;
if (!filtered.length) {
tasksList.innerHTML = ``;
return;
}
tasksList.innerHTML = filtered.map(t => {
const bucket = t.bucketName ? esc(t.bucketName) : 'unsorted';
return `
${esc(t.text)}
`;
}).join('');
tasksList.querySelectorAll('[data-task-toggle]').forEach(btn => {
btn.addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
const [cardId, todoIdx] = btn.dataset.taskToggle.split(':');
await toggleTask(cardId, Number(todoIdx));
});
});
tasksList.querySelectorAll('[data-task-open]').forEach(btn => {
btn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
closeTasks();
openDetail(btn.dataset.taskOpen);
});
});
}
async function toggleTask(cardId, todoIdx) {
const card = currentCards.find(c => c.id == cardId);
if (!card) return;
let idx = 0;
let changed = false;
const nextBody = (card.body || '').replace(/- \[([ xX])\]/g, (m, state) => {
if (idx++ !== todoIdx) return m;
changed = true;
return `- [${state.toLowerCase() === 'x' ? ' ' : 'x'}]`;
});
if (!changed) return;
const updated = await api(`api/cards/${cardId}`, { method: 'PATCH', body: { body: nextBody } });
if (updated && updated.id) {
const pos = currentCards.findIndex(c => c.id == updated.id);
if (pos !== -1) currentCards[pos] = updated;
} else {
card.body = nextBody;
}
if (viewingCard && viewingCard.id == cardId) {
const latest = currentCards.find(c => c.id == cardId);
if (latest) {
viewingCard = latest;
if (detail.classList.contains('open')) showDetailContent(latest);
}
}
renderBoard();
}
function openTasks() {
if (!tasksOverlay) return;
if (searchOverlay.classList.contains('open')) closeSearch();
tasksOverlay.classList.add('open');
refreshTasksView();
}
function closeTasks() {
if (tasksOverlay) tasksOverlay.classList.remove('open');
}
if (tasksBtn) tasksBtn.addEventListener('click', openTasks);
if (tasksCloseBtn) tasksCloseBtn.addEventListener('click', closeTasks);
if (tasksOverlay) tasksOverlay.addEventListener('click', e => { if (e.target === tasksOverlay) closeTasks(); });
if (tasksStatusFilter) tasksStatusFilter.addEventListener('change', refreshTasksView);
if (tasksBucketFilter) tasksBucketFilter.addEventListener('change', refreshTasksView);
// ── Global keyboard ──
document.addEventListener('keydown', e => {
const anyOpen = modal.classList.contains('open') || detail.classList.contains('open') || searchOverlay.classList.contains('open') || tasksOverlay?.classList.contains('open');
const dialogOpen = dialogOverlay.classList.contains('open');
if (dialogOpen) return; // dialog handles its own keys
if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); searchOverlay.classList.contains('open') ? closeSearch() : openSearch(); return; }
if (e.key === 'Escape') {
if (detail.classList.contains('open')) closeDetail();
else if (modal.classList.contains('open')) closeModal();
else if (searchOverlay.classList.contains('open')) closeSearch();
else if (tasksOverlay?.classList.contains('open')) closeTasks();
return;
}
if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); saveCard(); return; }
if (e.key === '/' && !anyOpen && document.activeElement === document.body) { e.preventDefault(); openSearch(); }
// Arrow keys navigate group in detail view
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && detail.classList.contains('open') && viewingCard) {
const groupCards = getGroupCards(viewingCard);
if (!groupCards || groupCards.length < 2) return;
const idx = groupCards.findIndex(c => c.id == viewingCard.id);
if (idx === -1) return;
e.preventDefault();
const next = e.key === 'ArrowRight'
? groupCards[(idx + 1) % groupCards.length]
: groupCards[(idx - 1 + groupCards.length) % groupCards.length];
showDetailCard(next.id);
}
});
// ── Export / Import ──
const exportBtn = document.getElementById('export-btn');
if (exportBtn) {
exportBtn.addEventListener('click', () => {
window.location.href = 'api/export';
});
}
const importBtn = document.getElementById('import-btn');
const importFile = document.getElementById('import-file');
if (importBtn && importFile) {
importBtn.addEventListener('click', () => {
importFile.click();
});
importFile.addEventListener('change', async e => {
const file = e.target.files?.[0];
if (!file) return;
const ok = await showDialog({
title: 'import_data',
message: 'Alle bestehenden Daten werden ersetzt. Fortfahren?',
danger: true,
okLabel: 'import',
});
if (!ok) { e.target.value = ''; return; }
const form = new FormData();
form.append('file', file);
try {
const res = await fetch('api/import', { method: 'POST', body: form });
const text = await res.text();
const data = JSON.parse(text);
if (data.error) throw new Error(data.error);
await loadBuckets();
await reloadAll();
} catch (err) {
console.error('Import failed:', err);
}
e.target.value = '';
});
}
// ── Auth ──
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', async () => {
const ok = await showDialog({ title: 'logout', message: 'Abmelden?', okLabel: 'logout', danger: true });
if (!ok) return;
await fetch('api/auth/logout', { method: 'POST' });
location.reload();
});
}
async function checkAuth() {
const res = await fetch('api/auth/status');
const data = await res.json();
if (data.setup) {
// First-time setup
let pw = null;
while (!pw || pw.length < 4) {
pw = await showDialog({
title: 'setup',
message: 'Willkommen bei hyprcrd. Setze ein Passwort:',
input: 'password',
placeholder: '> passwort (min. 4 zeichen)...',
okLabel: 'set',
cancelLabel: '',
});
if (pw && pw.length < 4) pw = null;
}
const r = await fetch('api/auth/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
});
const d = await r.json();
if (d.error) { location.reload(); return; }
} else if (!data.logged_in) {
// Login required
let ok = false;
while (!ok) {
const pw = await showDialog({
title: 'login',
message: '',
input: 'password',
placeholder: '> passwort...',
okLabel: 'login',
cancelLabel: '',
});
if (!pw) continue;
const r = await fetch('api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
});
const d = await r.json();
if (d.ok) { ok = true; }
}
}
// Authenticated — load app
loadBuckets();
loadCards();
}
// ── Boot ──
checkAuth();