@@ -446,6 +451,9 @@ export function renderBoard() {
// Footer count
const fc = $('footer-count');
if (fc) fc.textContent = `${cards.length} card${cards.length !== 1 ? 's' : ''} · ${state.cards.length} total`;
+
+ // Refresh split preview if open
+ if (boardSplitOpen && boardPreviewCardId) showBoardPreview(boardPreviewCardId);
}
// ── Group bar ──
diff --git a/public/js/modules/calendar.js b/public/js/modules/calendar.js
index ad54b4a..645bd1a 100644
--- a/public/js/modules/calendar.js
+++ b/public/js/modules/calendar.js
@@ -4,6 +4,7 @@ import { $, esc } from './util.js';
import { state } from './state.js';
import { stripMd, getTodos } from './markdown.js';
import { autoSync, getCalConfig, getAllCalConfigs } from './caldav.js';
+import { renderCardHtml } from './board.js';
const overlay = $('calendar');
const grid = $('cal-grid');
@@ -138,6 +139,12 @@ function isAppointment(card) {
return tags.includes('termin') || card.caldav_uid || /^\d{4}-\d{2}-\d{2}\s*[—-]\s*.+/.test(card.title);
}
+let calHover = null, calHoverTimer = null;
+function clearCalHover() {
+ if (calHoverTimer) { clearTimeout(calHoverTimer); calHoverTimer = null; }
+ if (calHover) { calHover.remove(); calHover = null; }
+}
+
function render() {
title.textContent = `${MONTHS[viewMonth]} ${viewYear}`;
@@ -234,11 +241,44 @@ function render() {
grid.innerHTML = html;
+ // Hover card preview — clear any stale popups from previous render
+ clearCalHover();
+
// Click on card item → open card (hide calendar, restore on return)
grid.querySelectorAll('[data-card-id]').forEach(el => {
let clickTimer = null;
+
+ el.addEventListener('mouseenter', () => {
+ clearCalHover();
+ calHoverTimer = setTimeout(() => {
+ const card = state.cards.find(c => c.id == el.dataset.cardId);
+ if (!card) return;
+ const rect = el.getBoundingClientRect();
+ const textEl = el.querySelector('.truncate') || el;
+ const textRect = textEl.getBoundingClientRect();
+ calHover = document.createElement('div');
+ Object.assign(calHover.style, {
+ position: 'fixed', zIndex: '210', width: '280px',
+ filter: 'drop-shadow(0 8px 24px rgba(0,0,0,0.4))',
+ });
+ const left = textRect.right + 4;
+ const useLeft = left + 290 < window.innerWidth;
+ calHover.style.left = (useLeft ? left : textRect.left - 284) + 'px';
+ calHover.style.top = Math.min(rect.top - 10, window.innerHeight - 320) + 'px';
+ calHover.innerHTML = renderCardHtml(card);
+ calHover.addEventListener('mouseenter', () => { if (calHoverTimer) { clearTimeout(calHoverTimer); calHoverTimer = null; } });
+ calHover.addEventListener('mouseleave', () => clearCalHover());
+ calHover.addEventListener('mouseover', e => e.stopPropagation());
+ document.body.appendChild(calHover);
+ }, 400);
+ });
+ el.addEventListener('mouseleave', () => {
+ if (calHoverTimer) { clearTimeout(calHoverTimer); calHoverTimer = null; }
+ setTimeout(() => { if (calHover && !calHover.matches(':hover')) clearCalHover(); }, 150);
+ });
el.addEventListener('click', e => {
e.stopPropagation();
+ clearCalHover();
if (clickTimer) return;
clickTimer = setTimeout(() => {
clickTimer = null;
@@ -250,6 +290,7 @@ function render() {
});
el.addEventListener('dblclick', e => {
e.stopPropagation();
+ clearCalHover();
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
closeDayPopup();
state._returnToCalendar = true;
@@ -425,15 +466,26 @@ function openDayPopup(ds, cellEl) {
});
});
- // Close on click outside
+ // Close on click outside or ESC
setTimeout(() => {
- const closeHandler = e => {
+ const clickHandler = e => {
if (dayPopup && !dayPopup.contains(e.target)) {
closeDayPopup();
- document.removeEventListener('click', closeHandler);
+ cleanup();
}
};
- document.addEventListener('click', closeHandler);
+ const keyHandler = e => {
+ if (e.key === 'Escape') {
+ closeDayPopup();
+ cleanup();
+ }
+ };
+ const cleanup = () => {
+ document.removeEventListener('click', clickHandler);
+ document.removeEventListener('keydown', keyHandler);
+ };
+ document.addEventListener('click', clickHandler);
+ document.addEventListener('keydown', keyHandler);
}, 0);
}
diff --git a/public/js/modules/context-menu.js b/public/js/modules/context-menu.js
index c3926f7..43c7a96 100644
--- a/public/js/modules/context-menu.js
+++ b/public/js/modules/context-menu.js
@@ -14,6 +14,14 @@ const tags = $('ctx-tags');
export function hideContextMenu() { menu.classList.add('hidden'); }
+let ctxCloseTimer = null;
+menu.addEventListener('mouseleave', () => {
+ ctxCloseTimer = setTimeout(hideContextMenu, 400);
+});
+menu.addEventListener('mouseenter', () => {
+ if (ctxCloseTimer) { clearTimeout(ctxCloseTimer); ctxCloseTimer = null; }
+});
+
document.addEventListener('click', hideContextMenu);
document.addEventListener('contextmenu', e => { if (!menu.contains(e.target)) hideContextMenu(); });
@@ -26,6 +34,7 @@ export function showContextMenu(x, y, card) {
+ ${/^\d{4}-\d{2}-\d{2}/.test(card.title) ? `
` : ''}
@@ -155,7 +164,30 @@ export function showContextMenu(x, y, card) {
const a = btn.dataset.action;
if (a === 'open') { hideContextMenu(); window._openDetail(card.id); }
else if (a === 'edit') { hideContextMenu(); window._openModal(card); }
- else if (a === '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 window._reloadAll(); }
+ else if (a === 'duplicate') {
+ hideContextMenu();
+ const hasDate = /^\d{4}-\d{2}-\d{2}/.test(card.title);
+ if (hasDate) {
+ const curDate = card.title.substring(0, 10);
+ const newDate = await showDialog({ title: 'duplicate_to_date', input: 'date', defaultValue: curDate, okLabel: 'duplicate' });
+ if (!newDate) return;
+ const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
+ await api('api/cards', { method: 'POST', body: { title: newTitle, body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
+ } else {
+ 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 window._reloadAll();
+ }
+ else if (a === 'move-date') {
+ hideContextMenu();
+ const curDate = card.title.substring(0, 10);
+ const newDate = await showDialog({ title: 'move_to_date', input: 'date', defaultValue: curDate, okLabel: 'move' });
+ if (!newDate || newDate === curDate) return;
+ const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
+ await api(`api/cards/${card.id}`, { method: 'PATCH', body: { title: newTitle } });
+ await window._reloadAll();
+ if (card.caldav_uid) window._triggerSync?.();
+ }
else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
else if (a === 'color') toggle(colors);
diff --git a/public/js/modules/detail.js b/public/js/modules/detail.js
index d2ef88b..5d9fe4f 100644
--- a/public/js/modules/detail.js
+++ b/public/js/modules/detail.js
@@ -458,17 +458,21 @@ function showContent(card) {
}
export function bindCheckboxes(container, card) {
- container.querySelectorAll('input[type="checkbox"]').forEach((cb, idx) => {
- cb.disabled = false;
- cb.addEventListener('change', async () => {
+ container.querySelectorAll('.todo-item').forEach((item, idx) => {
+ const btn = item.querySelector('.todo-cb');
+ if (!btn) return;
+ const toggle = async () => {
let n = 0;
const body = card.body.replace(/- \[([ x])\]/g, (m, s) => n++ === idx ? `- [${s === 'x' ? ' ' : 'x'}]` : m);
card.body = body;
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body } });
const c = state.cards.find(x => x.id == card.id);
if (c) c.body = body;
+ btn.classList.toggle('checked');
+ item.classList.toggle('todo-done');
window._renderBoard();
- });
+ };
+ item.addEventListener('click', toggle);
});
metaEl.textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at +
diff --git a/public/js/modules/dialog.js b/public/js/modules/dialog.js
index 88030cb..24ff9a6 100644
--- a/public/js/modules/dialog.js
+++ b/public/js/modules/dialog.js
@@ -6,32 +6,135 @@ const overlay = $('dialog');
const titleEl = $('dialog-title');
const msgEl = $('dialog-msg');
const inputEl = $('dialog-input');
+const datepickerEl = $('dialog-datepicker');
const cancelBtn = $('dialog-cancel');
const okBtn = $('dialog-ok');
const closeBtn = $('dialog-close');
let resolve = null;
+let dpMode = false;
+let dpYear, dpMonth, dpSelected;
+
+const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function canCancel() { return !cancelBtn.classList.contains('hidden'); }
function close(result) {
overlay.classList.remove('open');
+ datepickerEl.classList.add('hidden');
+ datepickerEl.innerHTML = '';
+ dpMode = false;
if (resolve) { resolve(result); resolve = null; }
}
function submit() {
- close(inputEl.classList.contains('hidden') ? true : inputEl.value);
+ if (dpMode) {
+ close(dpSelected || null);
+ } else {
+ close(inputEl.classList.contains('hidden') ? true : inputEl.value);
+ }
}
-export function showDialog({ title, message = '', input = false, placeholder = '', danger = false, okLabel = 'ok', cancelLabel = 'cancel' }) {
+function pad(n) { return String(n).padStart(2, '0'); }
+
+function renderDatepicker() {
+ const today = new Date();
+ const todayStr = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
+ const firstDay = new Date(dpYear, dpMonth, 1).getDay();
+ const startOffset = (firstDay + 6) % 7;
+ const daysInMonth = new Date(dpYear, dpMonth + 1, 0).getDate();
+ const prevDays = new Date(dpYear, dpMonth, 0).getDate();
+
+ let html = `
`;
+
+ for (const d of DAYS) html += `
${d}
`;
+
+ for (let i = 0; i < 42; i++) {
+ const dayNum = i - startOffset + 1;
+ let ds, display, outClass = '';
+ if (dayNum < 1) {
+ display = prevDays + dayNum;
+ const pm = dpMonth === 0 ? 11 : dpMonth - 1;
+ const py = dpMonth === 0 ? dpYear - 1 : dpYear;
+ ds = `${py}-${pad(pm + 1)}-${pad(display)}`;
+ outClass = 'dp-out';
+ } else if (dayNum > daysInMonth) {
+ display = dayNum - daysInMonth;
+ const nm = dpMonth === 11 ? 0 : dpMonth + 1;
+ const ny = dpMonth === 11 ? dpYear + 1 : dpYear;
+ ds = `${ny}-${pad(nm + 1)}-${pad(display)}`;
+ outClass = 'dp-out';
+ } else {
+ display = dayNum;
+ ds = `${dpYear}-${pad(dpMonth + 1)}-${pad(dayNum)}`;
+ }
+ const isToday = ds === todayStr ? 'dp-today' : '';
+ const isSel = ds === dpSelected ? 'dp-selected' : '';
+ html += `
${display}
`;
+ }
+
+ html += '
';
+ datepickerEl.innerHTML = html;
+
+ // Nav buttons
+ datepickerEl.querySelectorAll('[data-dp-nav]').forEach(btn => {
+ btn.addEventListener('click', e => {
+ e.preventDefault();
+ dpMonth += parseInt(btn.dataset.dpNav);
+ if (dpMonth > 11) { dpMonth = 0; dpYear++; }
+ if (dpMonth < 0) { dpMonth = 11; dpYear--; }
+ renderDatepicker();
+ });
+ });
+
+ // Day click
+ datepickerEl.querySelectorAll('[data-dp-date]').forEach(day => {
+ day.addEventListener('click', () => {
+ dpSelected = day.dataset.dpDate;
+ // Parse selected date to navigate to its month
+ const [y, m] = dpSelected.split('-').map(Number);
+ if (y !== dpYear || m - 1 !== dpMonth) {
+ dpYear = y;
+ dpMonth = m - 1;
+ }
+ renderDatepicker();
+ });
+ day.addEventListener('dblclick', () => {
+ dpSelected = day.dataset.dpDate;
+ submit();
+ });
+ });
+}
+
+export function showDialog({ title, message = '', input = false, placeholder = '', defaultValue = '', danger = false, okLabel = 'ok', cancelLabel = 'cancel' }) {
return new Promise(res => {
resolve = res;
titleEl.textContent = title;
msgEl.textContent = message;
msgEl.classList.toggle('hidden', !message);
- inputEl.classList.toggle('hidden', input === false);
- inputEl.value = '';
- inputEl.placeholder = placeholder;
- inputEl.type = input === 'password' ? 'password' : 'text';
+ dpMode = input === 'date';
+
+ if (dpMode) {
+ inputEl.classList.add('hidden');
+ datepickerEl.classList.remove('hidden');
+ const d = defaultValue ? new Date(defaultValue + 'T00:00:00') : new Date();
+ dpYear = d.getFullYear();
+ dpMonth = d.getMonth();
+ dpSelected = defaultValue || `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
+ renderDatepicker();
+ } else {
+ datepickerEl.classList.add('hidden');
+ datepickerEl.innerHTML = '';
+ inputEl.classList.toggle('hidden', input === false);
+ inputEl.value = defaultValue;
+ inputEl.placeholder = placeholder;
+ inputEl.type = input === 'password' ? 'password' : 'text';
+ }
+
okBtn.textContent = okLabel;
okBtn.classList.toggle('border-t-danger', danger);
okBtn.classList.toggle('text-t-danger', danger);
@@ -41,7 +144,7 @@ export function showDialog({ title, message = '', input = false, placeholder = '
cancelBtn.classList.toggle('hidden', !cancelLabel);
if (closeBtn) closeBtn.classList.toggle('hidden', !cancelLabel);
overlay.classList.add('open');
- if (input) inputEl.focus(); else okBtn.focus();
+ if (input && !dpMode) inputEl.focus(); else okBtn.focus();
});
}
diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js
index ad91fc6..6b31b11 100644
--- a/public/js/modules/editor.js
+++ b/public/js/modules/editor.js
@@ -151,6 +151,9 @@ export function openModal(card = null) {
}
inpTitle.addEventListener('input', updatePreview);
+inpTitle.addEventListener('keydown', e => {
+ if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); body.focus(); }
+});
// ── Line numbers ──
@@ -315,9 +318,8 @@ function updatePreview() {
addCodeCopyButtons(previewContent);
formatTodoItems(previewContent);
// Interactive checkboxes in editor preview — toggle in textarea
- previewContent.querySelectorAll('input[type="checkbox"]').forEach((cb, idx) => {
- cb.disabled = false;
- cb.addEventListener('change', () => {
+ previewContent.querySelectorAll('.todo-item').forEach((item, idx) => {
+ item.addEventListener('click', () => {
let n = 0;
body.value = body.value.replace(/- \[([ x])\]/g, (m, s) => n++ === idx ? `- [${s === 'x' ? ' ' : 'x'}]` : m);
updatePreview();
@@ -754,10 +756,17 @@ document.addEventListener('click', e => {
if (tagAcVisible && !tagAc.contains(e.target) && e.target !== body) hideTagAc();
if (snippetAcVisible && !snippetAc.contains(e.target) && e.target !== body) hideSnippetAc();
});
+// Capture phase: intercept ESC before dialog overlay can close the modal
document.addEventListener('keydown', e => {
- if (tagAcVisible && e.key === 'Escape') hideTagAc();
- if (snippetAcVisible && e.key === 'Escape') hideSnippetAc();
-});
+ if (e.key === 'Escape' && (tagAcVisible || snippetAcVisible || linkAcVisible)) {
+ e.stopImmediatePropagation();
+ e.stopPropagation();
+ e.preventDefault();
+ if (linkAcVisible) hideLinkAc();
+ if (tagAcVisible) hideTagAc();
+ if (snippetAcVisible) hideSnippetAc();
+ }
+}, true);
function completeTagAc(idx) {
const tag = tagAcItems[idx];
diff --git a/public/js/modules/markdown.js b/public/js/modules/markdown.js
index 2b9c64f..18f97d3 100644
--- a/public/js/modules/markdown.js
+++ b/public/js/modules/markdown.js
@@ -104,13 +104,25 @@ export function formatTodoItems(container) {
const cb = li.querySelector('input[type="checkbox"]');
if (!cb) return;
if (li.classList.contains('todo-item')) return;
+
+ const checked = cb.checked;
li.classList.add('todo-item');
- if (cb.checked) li.classList.add('todo-done');
- // Wrap remaining text nodes in a span
+ if (checked) li.classList.add('todo-done');
+
+ // Replace
with a styled button
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'todo-cb' + (checked ? ' checked' : '');
+ btn.innerHTML = '
';
+ btn.dataset.idx = cb.dataset.idx || '';
+ cb.replaceWith(btn);
+
+ // Wrap remaining content in a span
const span = document.createElement('span');
span.className = 'todo-text';
- while (cb.nextSibling) span.appendChild(cb.nextSibling);
+ while (btn.nextSibling) span.appendChild(btn.nextSibling);
li.appendChild(span);
+
});
}
diff --git a/public/js/modules/search.js b/public/js/modules/search.js
index 4aae54b..fb37b46 100644
--- a/public/js/modules/search.js
+++ b/public/js/modules/search.js
@@ -1,7 +1,7 @@
// ── Search ──
-import { $, esc, escRe, fuzzyMatch, countSubstring, highlight } from './util.js';
-import { state, parseTags, tagChipHtml } from './state.js';
+import { $, esc, escRe, api, fuzzyMatch, countSubstring, highlight } from './util.js';
+import { state, parseTags, tagChipHtml, getChildren } from './state.js';
import { renderMd, stripMd } from './markdown.js';
const overlay = $('search');
@@ -170,9 +170,12 @@ function hitHtml(card, idx, words) {
} else { snippet = esc(plain.substring(0, 120)) + (plain.length > 120 ? '...' : ''); }
const htags = parseTags(card);
- return `
+ const children = getChildren(card.id);
+ const isGroup = children.length > 0;
+ const canDrag = !isGroup && !card.parent_id;
+ return `
-
${title}
+
${title}${isGroup ? `${children.length + 1}` : ''}
${snippet}
${htags.length ? `
${htags.slice(0, 3).map(t => `#${esc(t)}`).join('')}
` : ''}
`;
}
@@ -185,6 +188,42 @@ function bindHits() {
else { active = i; updateActive(); }
});
el.addEventListener('dblclick', () => { const i = parseInt(el.dataset.idx); if (hits[i]) window._openDetail(hits[i].id); });
+
+ // Drag: only non-group, non-child cards
+ if (el.draggable) {
+ el.addEventListener('dragstart', e => {
+ e.dataTransfer.setData('text/plain', el.dataset.cardId);
+ e.dataTransfer.effectAllowed = 'move';
+ el.style.opacity = '0.4';
+ });
+ el.addEventListener('dragend', () => { el.style.opacity = ''; });
+ }
+
+ // Drop target: any card can receive (to form group)
+ el.addEventListener('dragover', e => {
+ const dragId = e.dataTransfer.types.includes('text/plain') ? true : false;
+ if (!dragId) return;
+ if (el.dataset.cardId === e.dataTransfer.getData('text/plain')) return;
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ el.style.background = 'var(--t-raised)';
+ el.style.borderLeft = `3px solid var(--t-accent)`;
+ });
+ el.addEventListener('dragleave', () => {
+ el.style.background = '';
+ el.style.borderLeft = '';
+ });
+ el.addEventListener('drop', async e => {
+ el.style.background = '';
+ el.style.borderLeft = '';
+ const dragId = e.dataTransfer.getData('text/plain');
+ const targetId = el.dataset.cardId;
+ if (!dragId || !targetId || dragId === targetId) return;
+ e.preventDefault();
+ await api(`api/cards/${dragId}`, { method: 'PATCH', body: { parent_id: targetId } });
+ await window._reloadAll();
+ search(input.value);
+ });
});
}
diff --git a/src/CalDavClient.php b/src/CalDavClient.php
index 0fb71ab..48f9391 100644
--- a/src/CalDavClient.php
+++ b/src/CalDavClient.php
@@ -190,7 +190,8 @@ class CalDavClient
$block = $m[1];
$get = function ($prop) use ($block) {
- if (preg_match('/^' . preg_quote($prop, '/') . '[^:]*:(.+)$/m', $block, $m)) return trim($m[1]);
+ // Match property value: everything after "PROP...:" until the next property or end
+ if (preg_match('/^' . preg_quote($prop, '/') . '[^:]*:([\s\S]*?)(?=\n[A-Z][-A-Z]*[;:]|\z)/m', $block, $m)) return trim($m[1]);
return null;
};
diff --git a/src/CalDavSync.php b/src/CalDavSync.php
index 8a1299a..0fa5088 100644
--- a/src/CalDavSync.php
+++ b/src/CalDavSync.php
@@ -171,7 +171,7 @@ class CalDavSync
$location = '';
if (preg_match('/\*\*Ort:\*\*\s*(.+)/i', $body, $lm)) $location = trim($lm[1]);
$description = '';
- if (preg_match('/\*\*Notizen:\*\*\s*\n([\s\S]*?)(?:\n\n#|\z)/i', $body, $nm)) $description = trim($nm[1]);
+ if (preg_match('/\*\*Notizen:\*\*\s*\n([\s\S]*?)(?=\n#[a-z]|\z)/i', $body, $nm)) $description = trim($nm[1]);
// Build DTSTART/DTEND
$dateCompact = str_replace('-', '', $date);
@@ -428,8 +428,11 @@ class CalDavSync
if (isset($remoteMap[$uid])) {
$remote = $remoteMap[$uid];
$remoteClient = $this->makeClient($remote['cal_url']);
- $localChanged = !$card['caldav_synced_at'] || $card['updated_at'] > $card['caldav_synced_at'];
- $remoteChanged = $card['caldav_etag'] !== $remote['etag'];
+ // Compare timestamps with 2s tolerance to avoid false positives from sync-triggered updates
+ $localTime = strtotime($card['updated_at'] ?? '2000-01-01');
+ $syncTime = strtotime($card['caldav_synced_at'] ?? '2000-01-01');
+ $localChanged = !$card['caldav_synced_at'] || ($localTime - $syncTime) > 2;
+ $remoteChanged = !$card['caldav_etag'] || $card['caldav_etag'] !== $remote['etag'];
// Always backfill rrule if missing locally but present remotely
$remoteRRule = $remote['rrule'] ?? null;
@@ -440,7 +443,6 @@ class CalDavSync
}
if ($localChanged && $remoteChanged) {
- $localTime = strtotime($card['updated_at']);
$remoteTime = $remote['last_modified'] ? strtotime($remote['last_modified']) : 0;
if ($localTime >= $remoteTime) {
$this->pushCard($remoteClient, $card, $remote['href'], $remote['etag']);