1807 lines
75 KiB
JavaScript
1807 lines
75 KiB
JavaScript
/* ══════════════════════════════════════
|
|
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 `<span class="card-link" onclick="openDetail(${card.id})">${esc(title)}</span>`;
|
|
return `<span class="card-link broken" title="card not found">${esc(title)}</span>`;
|
|
});
|
|
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(/<img[^>]+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(/^<img[^>]+src=(['"])(.*?)\1/i);
|
|
return html ? html[2] : null;
|
|
}
|
|
|
|
function stripMd(src) {
|
|
return (src || '')
|
|
.replace(/<img[^>]*>/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 = `<span class="w-3 h-3 rounded-full shrink-0 border border-t-border" style="background:${t.vars['--t-accent']}"></span>
|
|
<span class="font-pixel text-[10px]">${t.label}</span>`;
|
|
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 = `
|
|
<button class="${tabClass(activeBucket === 'all')}" data-bucket="all">
|
|
<i class="ph ph-squares-four text-sm"></i><span class="font-pixel text-[10px]">all</span>
|
|
</button>
|
|
<button class="${tabClass(activeBucket === 'none')}" data-bucket="none">
|
|
<i class="ph ph-tray text-sm"></i><span class="font-pixel text-[10px]">unsorted</span>
|
|
</button>
|
|
${currentBuckets.map(b => `
|
|
<button class="${tabClass(activeBucket == b.id)}" data-bucket="${b.id}">
|
|
<i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span>
|
|
</button>`).join('')}
|
|
<button id="add-bucket-btn" class="flex items-center gap-1 px-2 py-1 text-xs text-t-dim hover:text-t-accent transition-colors shrink-0">
|
|
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">bucket</span>
|
|
</button>`;
|
|
|
|
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 = `<option value="">no bucket</option>` +
|
|
currentBuckets.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
|
|
searchBucketFilter.innerHTML = `<option value="">all buckets</option><option value="none">unsorted</option>` +
|
|
currentBuckets.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
|
|
if (tasksBucketFilter) {
|
|
const current = tasksBucketFilter.value;
|
|
tasksBucketFilter.innerHTML = `<option value="">all buckets</option><option value="none">unsorted</option>` +
|
|
currentBuckets.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).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 = `
|
|
<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>`;
|
|
groupBar.querySelector('#group-back').addEventListener('click', () => { activeGroup = null; renderBoard(); });
|
|
} else {
|
|
groupBar.classList.add('hidden');
|
|
}
|
|
|
|
if (!cards.length) {
|
|
board.innerHTML = `<div class="col-span-full text-center py-20">
|
|
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
|
|
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
|
|
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 `
|
|
<div class="${isGroup ? 'card-group ' : ''}card-glow ${hasCover ? 'card-has-cover' : 'bg-t-surface'} border border-t-border cursor-pointer hover:border-t-muted transition-colors relative group"
|
|
style="--glow-color:${c.color}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + c.color : ''}" draggable="true" data-id="${c.id}">
|
|
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
|
|
<div class="card-bar w-full card-layer" style="background:${c.color}"></div>
|
|
${isGroup ? `<div class="card-group-badge font-pixel cursor-pointer hover:scale-110 transition-transform" style="background:${c.color};color:#000" data-group="${c.id}">${children.length + 1}</div>` : ''}
|
|
${thumbImg ? `<div class="card-thumb" style="background-image:url('${thumbImg}')"></div>` : ''}
|
|
<div class="p-4 card-layer ${thumbImg ? 'pr-16' : ''}">
|
|
<div class="flex items-start gap-2 mb-2">
|
|
${todos ? `<i class="ph ${todos.done === todos.total ? 'ph-check-square text-t-accent' : 'ph-squares-four text-t-muted'} text-sm mt-0.5 shrink-0"></i>` : ''}
|
|
<h3 class="font-pixel text-xs text-t-bright leading-relaxed">${esc(c.title)}</h3>
|
|
</div>
|
|
${todos ? `
|
|
<div class="mb-2">
|
|
<div class="flex items-center gap-2 mb-1">
|
|
<div class="flex-1 h-1.5 bg-t-border rounded-full overflow-hidden">
|
|
<div class="h-full rounded-full transition-all" style="width:${todoPct}%;background:${c.color}"></div>
|
|
</div>
|
|
<span class="font-pixel text-[9px] ${todos.done === todos.total ? 'text-t-accent' : 'text-t-dim'}">${todos.done}/${todos.total}</span>
|
|
</div>
|
|
</div>` : ''}
|
|
<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed line-clamp-3">${esc(stripMd(c.body))}</p>
|
|
${c.bucket_name ? `<div class="mt-2 flex items-center gap-1 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</div>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}).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 `
|
|
<div class="sidebar-card flex items-center gap-2 px-3 py-2.5 cursor-pointer border-l-2 transition-colors
|
|
${active ? 'border-t-accent bg-t-raised' : 'border-transparent hover:bg-t-raised/50'}"
|
|
data-sid="${gc.id}">
|
|
<span class="w-2 h-2 rounded-full shrink-0" style="background:${gc.color}"></span>
|
|
${img ? `<span class="w-6 h-6 rounded-full shrink-0 bg-cover bg-center border border-t-border" style="background-image:url('${img}')"></span>` : ''}
|
|
<span class="font-pixel text-[10px] ${active ? 'text-t-bright' : 'text-t-muted'} truncate flex-1">${esc(gc.title)}</span>
|
|
<button onclick="event.stopPropagation();ungroupCard(${gc.id})" class="text-t-dim hover:text-t-danger transition-colors shrink-0 ${gc.id == (groupCards[0]?.id) ? 'invisible' : ''}" title="ungroup">
|
|
<i class="ph ph-x text-[10px]"></i>
|
|
</button>
|
|
</div>`;
|
|
}).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 => `
|
|
<button onclick="openDetail(${b.id})" class="flex items-center gap-1.5 px-2 py-1 text-xs border border-t-border text-t-muted hover:border-t-accent2 hover:text-t-accent2 transition-colors">
|
|
<span class="w-1.5 h-1.5 rounded-full shrink-0" style="background:${b.color}"></span>
|
|
<span class="font-pixel text-[10px]">${esc(b.title)}</span>
|
|
</button>`).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) => `
|
|
<div class="ac-item ${i === 0 ? 'active' : ''}" data-idx="${i}">
|
|
<span class="ac-title font-pixel">${esc(c.title)}</span>
|
|
${c.bucket_name ? `<span class="ac-bucket font-pixel">${esc(c.bucket_name)}</span>` : ''}
|
|
</div>`).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 = `
|
|
<button class="ctx-item" data-action="open"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">open</span></button>
|
|
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">edit</span></button>
|
|
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">duplicate</span></button>
|
|
<div class="ctx-sep"></div>
|
|
<button class="ctx-item" data-action="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
|
<button class="ctx-item" data-action="bucket"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">bucket</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
|
${isGroupParent ? `
|
|
<div class="ctx-sep"></div>
|
|
<button class="ctx-item" data-action="expand-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">open group</span></button>
|
|
<button class="ctx-item" data-action="group-members"><i class="ph ph-list text-sm"></i><span class="font-pixel text-[10px]">members (${children.length + 1})</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
|
<button class="ctx-item" data-action="dissolve"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">dissolve group</span></button>
|
|
` : ''}
|
|
${isChild ? `
|
|
<div class="ctx-sep"></div>
|
|
<button class="ctx-item" data-action="go-to-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">go to group</span></button>
|
|
<button class="ctx-item" data-action="ungroup"><i class="ph ph-arrow-line-up text-sm"></i><span class="font-pixel text-[10px]">ungroup</span></button>
|
|
` : ''}
|
|
<div class="ctx-sep"></div>
|
|
<button class="ctx-item danger" data-action="delete"><i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span></button>
|
|
`;
|
|
|
|
// Color sub-panel
|
|
const ctxColorGrid = document.getElementById('ctx-color-grid');
|
|
ctxColorGrid.innerHTML = cardColors.map(c =>
|
|
`<button class="w-5 h-5 rounded-sm border-2 transition-all hover:scale-125 ${c.hex === card.color ? 'border-t-bright' : 'border-transparent'}" style="background:${c.hex}" data-ctx-color="${c.hex}"></button>`
|
|
).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 => `
|
|
<button class="ctx-item" data-ctx-member="${gc.id}">
|
|
<span class="w-2 h-2 rounded-full shrink-0" style="background:${gc.color}"></span>
|
|
<span class="font-pixel text-[10px] truncate flex-1">${esc(gc.title)}</span>
|
|
${gc.id !== card.id ? `<i class="ph ph-arrow-line-up text-[10px] text-t-dim hover:text-t-danger" data-ctx-kick="${gc.id}"></i>` : ''}
|
|
</button>`).join('');
|
|
}
|
|
ctxMembers.classList.add('hidden');
|
|
|
|
// Bucket sub-panel
|
|
ctxBuckets.innerHTML = `
|
|
<button class="ctx-item ${!card.bucket_id ? 'text-t-accent' : ''}" data-ctx-bucket=""><i class="ph ph-x text-sm"></i><span class="font-pixel text-[10px]">no bucket</span></button>
|
|
${currentBuckets.map(b => `
|
|
<button class="ctx-item ${card.bucket_id == b.id ? 'text-t-accent' : ''}" data-ctx-bucket="${b.id}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>
|
|
`).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 <img...src="..."> and set width via HTML img tag
|
|
const mdPattern = new RegExp('!\\[([^\\]]*)\\]\\(' + escRe(src) + '\\)');
|
|
const match = val.match(mdPattern);
|
|
if (match) {
|
|
const replacement = `<img src="${src}" alt="${match[1]}" width="${width}">`;
|
|
editorBody.value = val.replace(match[0], replacement);
|
|
updatePreview();
|
|
} else {
|
|
// Already an <img> tag — update width
|
|
const imgTagRe = new RegExp('(<img[^>]*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<mark>$3</mark>');
|
|
}
|
|
}
|
|
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 = `<div class="px-4 py-8 text-center text-t-dim text-xs font-pixel">no cards yet</div>`;
|
|
return;
|
|
}
|
|
searchResults.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">recent</span></div>
|
|
${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 = `<div class="px-4 py-8 text-center"><i class="ph ph-magnifying-glass text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel">no results for "${esc(query)}"</p></div>`;
|
|
searchFooter.classList.add('hidden');
|
|
clearSearchPreview();
|
|
return;
|
|
}
|
|
|
|
searchResults.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${scored.length} result${scored.length > 1 ? 's' : ''}</span></div>
|
|
${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 `<div class="search-hit flex items-start gap-3 px-4 py-2.5 cursor-pointer transition-colors hover:bg-t-raised" data-idx="${idx}">
|
|
<span class="w-2 h-2 rounded-full shrink-0 mt-1.5" style="background:${card.color}"></span>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="text-xs text-t-bright font-pixel leading-relaxed truncate">${title}</div>
|
|
<div class="text-[11px] text-t-muted leading-relaxed mt-0.5 line-clamp-2">${snippet}</div>
|
|
</div></div>`;
|
|
}
|
|
|
|
function highlight(html, words) {
|
|
let result = html;
|
|
for (const w of words) result = result.replace(new RegExp('(' + escRe(w) + ')', 'gi'), '<mark>$1</mark>');
|
|
if (!result.includes('<mark>')) {
|
|
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 += '<mark>' + html[i] + '</mark>'; 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 = `<div class="px-4 py-10 text-center text-t-dim">
|
|
<i class="ph ph-check-circle text-3xl mb-2 block"></i>
|
|
<p class="font-pixel text-[10px]">no tasks in this filter</p>
|
|
</div>`;
|
|
return;
|
|
}
|
|
|
|
tasksList.innerHTML = filtered.map(t => {
|
|
const bucket = t.bucketName ? esc(t.bucketName) : 'unsorted';
|
|
return `<div class="px-4 py-3 border-b border-t-border flex items-start gap-3 hover:bg-t-raised/40">
|
|
<button data-task-toggle="${t.cardId}:${t.todoIndex}" class="group mt-0.5 shrink-0 border rounded-sm flex items-center justify-center transition-colors ${t.done ? 'border-t-accent bg-t-accent' : 'border-t-border bg-transparent hover:border-t-accent'}" style="width:30px;height:30px;">
|
|
<i class="ph ph-check text-[18px] transition-opacity ${t.done ? 'opacity-100 text-t-bg' : 'opacity-0 text-t-accent group-hover:opacity-75'}"></i>
|
|
</button>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="text-xs leading-relaxed ${t.done ? 'text-t-dim line-through' : 'text-t-text'}">${esc(t.text)}</div>
|
|
<button data-task-open="${t.cardId}" class="mt-1 inline-flex items-center gap-1.5 text-[10px] font-pixel text-t-muted hover:text-t-accent2 transition-colors">
|
|
<span class="w-1.5 h-1.5 rounded-full" style="background:${t.cardColor}"></span>
|
|
<span>${esc(t.cardTitle)}</span>
|
|
<span class="text-t-dim">/ ${bucket}</span>
|
|
</button>
|
|
</div>
|
|
</div>`;
|
|
}).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();
|