From 416ca9e854dde261bac81750720edec13caff606 Mon Sep 17 00:00:00 2001 From: th Date: Wed, 8 Apr 2026 17:24:55 +0200 Subject: [PATCH] kalender und fixes --- config.php | 2 +- dist.sh | 71 +++++++++++------- public/css/style.css | 33 +++++++-- public/index.php | 1 + public/js/modules/board.js | 18 +++-- public/js/modules/calendar.js | 60 ++++++++++++++- public/js/modules/context-menu.js | 34 ++++++++- public/js/modules/detail.js | 12 ++- public/js/modules/dialog.js | 117 ++++++++++++++++++++++++++++-- public/js/modules/editor.js | 21 ++++-- public/js/modules/markdown.js | 18 ++++- public/js/modules/search.js | 47 +++++++++++- src/CalDavClient.php | 3 +- src/CalDavSync.php | 10 ++- 14 files changed, 376 insertions(+), 71 deletions(-) diff --git a/config.php b/config.php index 25797bc..c93383f 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ /dev/null; then + echo "Minifying JS..." + for f in "$STAGE"/public/js/app.js "$STAGE"/public/js/modules/*.js; do + [ -f "$f" ] || continue + npx --yes terser "$f" --compress --mangle --module -o "$f" 2>/dev/null && \ + echo " $(basename "$f")" || echo " SKIP $(basename "$f")" + done + + echo "Minifying CSS..." + for f in "$STAGE"/public/css/style.css "$STAGE"/public/css/fonts.css; do + [ -f "$f" ] || continue + npx --yes csso-cli "$f" -o "$f" 2>/dev/null && \ + echo " $(basename "$f")" || echo " SKIP $(basename "$f")" + done +else + echo "npx not found, skipping minification" +fi + +# Create ZIP echo "Creating ${DIST}..." - -zip -r "$DIST" \ - config.php \ - Dockerfile \ - docker-entrypoint.sh \ - README.md \ - dist.sh \ - .gitignore \ - src/ \ - public/ \ - -x "public/uploads/*" \ - -x "public/uploads/.gitkeep" \ - -x "public/css/tailwind.css" - -# Add empty dirs with gitkeep -mkdir -p /tmp/hyprcrd-dist/data -mkdir -p /tmp/hyprcrd-dist/public/uploads -touch /tmp/hyprcrd-dist/data/.gitkeep -touch /tmp/hyprcrd-dist/public/uploads/.gitkeep -cd /tmp/hyprcrd-dist -zip -r "$OLDPWD/$DIST" data/.gitkeep public/uploads/.gitkeep +cd "$STAGE" +zip -r "$OLDPWD/$DIST" . -x "*.gitkeep" 2>/dev/null +# Re-add gitkeep files +zip "$OLDPWD/$DIST" data/.gitkeep public/uploads/.gitkeep cd "$OLDPWD" -rm -rf /tmp/hyprcrd-dist +# Cleanup +rm -rf "$STAGE" + +# Size comparison +ORIG=$(du -sb public/js/ public/css/style.css | awk '{s+=$1}END{print s}') +FINAL=$(stat -c%s "$DIST") echo "Done: ${DIST} ($(du -h "$DIST" | cut -f1))" diff --git a/public/css/style.css b/public/css/style.css index 340e947..33e3856 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -18,6 +18,19 @@ body { font-family: 'JetBrains Mono', monospace; } .cal-item-journal { opacity: 0.8; } .cal-todo { font-family: var(--t-font-pixel, 'Silkscreen', cursive); font-size: 8px; color: var(--t-accent); flex-shrink: 0; } +/* Datepicker */ +.dp-header { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; } +.dp-header button { background: none; border: 1px solid var(--t-border); color: var(--t-muted); cursor: pointer; padding: 2px 8px; border-radius: 3px; transition: color 0.1s, border-color 0.1s; } +.dp-header button:hover { color: var(--t-accent); border-color: var(--t-accent); } +.dp-title { flex: 1; text-align: center; font-family: var(--t-font-pixel); font-size: 10px; color: var(--t-bright); } +.dp-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; } +.dp-day-label { text-align: center; font-family: var(--t-font-pixel); font-size: 8px; color: var(--t-dim); padding: 2px 0; text-transform: uppercase; } +.dp-day { text-align: center; font-size: 11px; color: var(--t-text); padding: 5px 0; cursor: pointer; border-radius: 3px; transition: background 0.1s, color 0.1s; } +.dp-day:hover { background: var(--t-raised); color: var(--t-bright); } +.dp-day.dp-out { color: var(--t-dim); opacity: 0.4; } +.dp-day.dp-today { color: var(--t-accent); font-weight: bold; } +.dp-day.dp-selected { background: var(--t-accent); color: var(--t-bg); } + /* Scrollbars */ * { scrollbar-width: thin; @@ -561,20 +574,24 @@ body.detail-image-fs-open { overflow: hidden; } .prose blockquote { border-left: 3px solid var(--t-accent); padding-left: 1em; margin: 0.8em 0; color: var(--t-muted); } .prose hr { border: none; border-top: 1px solid var(--t-border); margin: 1.2em 0; } /* Todo component */ +.prose ul:has(.todo-item), .prose ol:has(.todo-item) { padding-left: 1.5em; } .todo-item { list-style: none !important; margin: 0 !important; margin-left: -1.5em !important; display: flex !important; + align-items: flex-start; gap: 10px; - padding: 4px 0; - border-bottom: 1px solid color-mix(in srgb, var(--t-border) 40%, transparent); + min-height: 28px; + padding: 5px 0; } -.todo-item:last-child { border-bottom: none; } +.todo-item { cursor: pointer; } +.todo-item:hover { background: color-mix(in srgb, var(--t-raised) 50%, transparent); } .todo-item.todo-done .todo-text { color: var(--t-dim); text-decoration: line-through; } -.todo-text { flex: 1; min-width: 0; line-height: 20px; } +.todo-text { flex: 1; min-width: 0; line-height: 1.5; user-select: none; } .todo-cb { - width: 20px; height: 20px; min-width: 20px; + width: 18px; height: 18px; min-width: 18px; + transform: translateY(-9px); border: 2px solid var(--t-border); border-radius: 3px; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; @@ -583,12 +600,16 @@ body.detail-image-fs-open { overflow: hidden; } } .todo-cb:hover { border-color: var(--t-accent); } .todo-cb.checked { background: var(--t-accent); border-color: var(--t-accent); } -.todo-cb i { font-size: 14px; transition: opacity 0.12s; } +.todo-cb i { font-size: 12px; transition: opacity 0.12s; } .todo-cb:not(.checked) i { opacity: 0; color: var(--t-accent); } .todo-cb:not(.checked):hover i { opacity: 0.5; } .todo-cb.checked i { opacity: 1; color: var(--t-bg); } .prose input[type="checkbox"] { display: none; } +/* Todo progress bar */ +.todo-progress { display: flex; flex: 1; gap: 3px; } +.todo-seg { flex: 1; height: 5px; border-radius: 3px; transition: background 0.2s; } + .prose audio { width: 100%; max-width: 400px; diff --git a/public/index.php b/public/index.php index ad6d577..7f00b63 100644 --- a/public/index.php +++ b/public/index.php @@ -882,6 +882,7 @@ if (str_starts_with($route, '/api/')) {

+
diff --git a/public/js/modules/board.js b/public/js/modules/board.js index c38c6ca..746ab2c 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -10,6 +10,12 @@ import { bindCheckboxes } from './detail.js'; const board = $('board'); const boardPreview = $('board-preview'); + +board?.addEventListener('contextmenu', e => { + if (e.target.closest('[data-id]') || e.target.closest('.superview-item')) return; + e.preventDefault(); + window._openModal(null); +}); const boardPreviewBody = $('board-preview-body'); const boardPreviewTitle = $('board-preview-title'); const boardPreviewColor = $('board-preview-color'); @@ -217,7 +223,7 @@ $('sort-toggle')?.addEventListener('click', () => { renderBoard(); }); -function renderCardHtml(c, inGroup = false, recent = []) { +export function renderCardHtml(c, inGroup = false, recent = []) { const coverImg = leadingImage(c.body); const thumbImg = coverImg ? null : firstImage(c.body); const children = getChildren(c.id); @@ -246,10 +252,9 @@ function renderCardHtml(c, inGroup = false, recent = []) { ${todos ? `` : ''}

${esc(c.title)}

- ${todos ? `
-
- ${todos.done}/${todos.total} -
` : ''} + ${todos ? `
+
${Array.from({length: todos.total}, (_, i) => `
`).join('')}
+
` : ''}

${esc(stripMd(c.body))}

${tags.length ? `
${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `+${tags.length - 5}` : ''}
` : ''}
@@ -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 = `
+ + ${MONTHS[dpMonth]} ${dpYear} + +
`; + + 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']);