diff --git a/config.php b/config.php index 39a3dcf..a11f0c6 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ $m[1]]; - foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date'] as $f) { + foreach (['title', 'body', 'color', 'position', 'bucket_id', 'parent_id', 'due_date', 'scratchpad'] as $f) { if (array_key_exists($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; } } if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); } @@ -2236,6 +2236,7 @@ if (str_starts_with($route, '/api/')) {
+
@@ -2264,6 +2265,14 @@ if (str_starts_with($route, '/api/')) {
+ diff --git a/public/js/app.js b/public/js/app.js index 423230a..25474df 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -214,7 +214,8 @@ const { board: gBoard } = guards; // — Escape chain (highest priority first) — registerShortcut({ key: 'Escape', priority: 100, guard: () => !isDialogOpen() && !document.getElementById('quick-capture').classList.contains('hidden'), section: 'global', label: 'close quick capture', hidden: true, handler: () => { const qc = document.getElementById('quick-capture'); qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; } }); -registerShortcut({ key: 'Escape', priority: 90, guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'global', label: 'close detail', hidden: true, handler: () => { const escAction = localStorage.getItem('hyprcrd-esc-action') || 'close'; if (escAction === 'exitZen' && document.getElementById('detail')?.classList.contains('zen')) { document.getElementById('detail-zen')?.click(); return; } closeDetail(); } }); +registerShortcut({ key: 'Escape', priority: 90, guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'global', label: 'close detail', hidden: true, handler: () => { if (document.getElementById('detail')?.classList.contains('zen')) { document.getElementById('detail-zen')?.click(); return; } closeDetail(); } }); +registerShortcut({ key: 'z', guard: () => !isDialogOpen() && guards.isOpen('detail'), section: 'detail', keys: 'Z', label: 'toggle zen mode', handler: () => document.getElementById('detail-zen')?.click() }); registerShortcut({ key: 'Escape', priority: 80, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'global', label: 'close editor', hidden: true, handler: () => closeModal() }); registerShortcut({ key: 'Escape', priority: 70, guard: () => !isDialogOpen() && guards.isOpen('search'), section: 'global', label: 'close search', hidden: true, handler: () => closeSearch() }); registerShortcut({ key: 'Escape', priority: 60, guard: () => !isDialogOpen() && guards.isOpen('tasks'), section: 'global', label: 'close tasks', hidden: true, handler: () => closeTasks() }); @@ -316,17 +317,23 @@ registerShortcut({ key: 'Enter', guard: () => gBoard() && !!document.querySelect const focused = document.querySelector('#board .card-focused'); if (focused) { window._openDetail(focused.dataset.id); clearCardFocus(); } }}); -// Delete selected cards -registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && getSelectedIds().length > 0, section: 'global', keys: 'Del', label: 'delete selected', hidden: true, handler: async () => { - const ids = getSelectedIds(); +// Delete selected or focused card +registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && (getSelectedIds().length > 0 || !!document.querySelector('#board .card-focused')), section: 'global', keys: '⌫', label: 'delete selected / focused', hidden: true, handler: async () => { + let ids = getSelectedIds(); + if (!ids.length) { + const focused = document.querySelector('#board .card-focused'); + if (focused) ids = [focused.dataset.id]; + } + if (!ids.length) return; if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') { const ok = await showDialog({ title: 'delete cards', message: `Delete ${ids.length} card${ids.length > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' }); if (!ok) return; } - const { animateCardRemoval } = await import('./modules/board.js'); + const { animateCardRemoval, clearCardFocus } = await import('./modules/board.js'); await animateCardRemoval(ids); for (const id of ids) await api(`api/cards/${id}`, { method: 'DELETE' }); clearSelection(); + clearCardFocus(); await window._reloadAll(); }}); diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js index 3af76ba..1b1be2f 100644 --- a/public/js/modules/editor.js +++ b/public/js/modules/editor.js @@ -189,12 +189,8 @@ export function openModal(card = null) { selectColor(card ? card.color : '#38bdf8'); setDueDate(card?.due_date || ''); updateDoneToggle(); - state.previewVisible = true; - preview.classList.remove('hidden'); - editorDivider?.classList.remove('hidden'); - $('toggle-preview').classList.add('text-t-accent'); - applyEditorSplit(); - updatePreview(); + if (scratchpadBody) scratchpadBody.value = card?.scratchpad || ''; + showSidePanel('preview'); modal.classList.add('open'); if (linesVisible) updateGutter(); currentLine = -1; @@ -396,7 +392,8 @@ function updatePreview() { } async function doSave(close, { animate = true } = {}) { - const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null }; + const scratch = scratchpadBody?.value || null; + const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null, due_date: inpDueValue?.value || null, scratchpad: scratch }; if (!data.title.trim()) return; const returnToGallery = state.returnToGalleryAfterEdit; if (state.editingId) { @@ -536,18 +533,43 @@ function applyEditorSplit() { preview.style.flex = `${100 - editorSplit} 1 0`; } -$('toggle-preview')?.addEventListener('click', () => { - state.previewVisible = !state.previewVisible; - preview.classList.toggle('hidden', !state.previewVisible); - editorDivider?.classList.toggle('hidden', !state.previewVisible); - $('toggle-preview').classList.toggle('text-t-accent', state.previewVisible); - if (state.previewVisible) { +const scratchpadPanel = $('editor-scratchpad'); +const scratchpadBody = $('scratchpad-body'); + +function showSidePanel(which) { + const showPreview = which === 'preview'; + const showScratch = which === 'scratchpad'; + + state.previewVisible = showPreview; + preview.classList.toggle('hidden', !showPreview); + scratchpadPanel?.classList.toggle('hidden', !showScratch); + editorDivider?.classList.toggle('hidden', !showPreview && !showScratch); + $('toggle-preview')?.classList.toggle('text-t-accent', showPreview); + $('toggle-scratchpad')?.classList.toggle('text-t-accent', showScratch); + + if (showPreview || showScratch) { applyEditorSplit(); + if (showScratch) scratchpadPanel.style.flex = preview.style.flex || '1 1 0%'; } else { if (editorPane) editorPane.style.flex = '1 1 0%'; preview.style.flex = ''; + if (scratchpadPanel) scratchpadPanel.style.flex = ''; } - updatePreview(); + if (showPreview) updatePreview(); +} + +$('toggle-preview')?.addEventListener('click', () => { + showSidePanel(state.previewVisible ? null : 'preview'); +}); + +$('toggle-scratchpad')?.addEventListener('click', () => { + const isOpen = !scratchpadPanel?.classList.contains('hidden'); + showSidePanel(isOpen ? null : 'scratchpad'); + if (!isOpen) scratchpadBody?.focus(); +}); + +$('scratchpad-clear')?.addEventListener('click', () => { + if (scratchpadBody) scratchpadBody.value = ''; }); if (editorDivider) { diff --git a/public/js/modules/tooltip.js b/public/js/modules/tooltip.js index a103782..5f90fb3 100644 --- a/public/js/modules/tooltip.js +++ b/public/js/modules/tooltip.js @@ -71,14 +71,16 @@ function hide() { } function onEnter(e) { - const target = e.target.closest('[data-tip],[data-help]'); + const el = e.target?.nodeType === 1 ? e.target : e.target?.parentElement; + const target = el?.closest?.('[data-tip],[data-help]'); if (!target) return; clearTimeout(hideTimer); showTimer = setTimeout(() => show(target), SHOW_DELAY); } function onLeave(e) { - const target = e.target.closest('[data-tip],[data-help]'); + const el = e.target?.nodeType === 1 ? e.target : e.target?.parentElement; + const target = el?.closest?.('[data-tip],[data-help]'); if (!target) return; clearTimeout(showTimer); hideTimer = setTimeout(hide, HIDE_DELAY); diff --git a/public/plugins/collab-buckets/backend.php b/public/plugins/collab-buckets/backend.php index cd71d43..1dc5a94 100644 --- a/public/plugins/collab-buckets/backend.php +++ b/public/plugins/collab-buckets/backend.php @@ -436,7 +436,8 @@ function collabSyncCards(PDO $db): array { FROM cards c INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL LEFT JOIN collab_card_state cs ON cs.card_id = c.id - WHERE cs.last_push_at IS NULL OR c.updated_at > cs.last_push_at + WHERE (cs.last_push_at IS NULL OR c.updated_at > cs.last_push_at) + AND c.updated_at < datetime('now', '-2 seconds') ")->fetchAll(); $assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id'); diff --git a/public/plugins/collab-buckets/collab-buckets.js b/public/plugins/collab-buckets/collab-buckets.js index 685c57a..8713873 100644 --- a/public/plugins/collab-buckets/collab-buckets.js +++ b/public/plugins/collab-buckets/collab-buckets.js @@ -58,8 +58,15 @@ async function loadCollabMeta() { } catch (err) { /* silent; relay may be offline */ } } +let pollLocked = false; + async function pollOnce() { + if (pollLocked) return; + // Don't poll while user is actively editing — the push would race + // with the in-progress save and corrupt card state. + if (document.getElementById('modal')?.classList.contains('open')) return; try { + pollLocked = true; const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' }); if (!res?.ok) return; if (res.self) window._collabSelf = res.self; @@ -112,7 +119,7 @@ async function pollOnce() { const by = Array.isArray(res.card_stats?.blocked_by) ? res.card_stats.blocked_by : []; showBlockedToast(by); } - } catch { /* silent */ } + } catch { /* silent */ } finally { pollLocked = false; } } function showConflictToast(n) { diff --git a/public/plugins/tests/rrule.test.js b/public/plugins/tests/rrule.test.js index 55424d0..93f4741 100644 --- a/public/plugins/tests/rrule.test.js +++ b/public/plugins/tests/rrule.test.js @@ -14,8 +14,6 @@ describe('RRULE: Data Model', () => { const birthdays = state.cards.filter(c => (c.tags || '').includes('geburtstag')); for (const b of birthdays) { expect(b.tags).toContain('geburtstag'); - // Birthday titles start with date - expect(b.title).toMatch(/^\d{4}-\d{2}-\d{2}/); } }); diff --git a/relay/ui/app.js b/relay/ui/app.js index 3cde133..0754255 100644 --- a/relay/ui/app.js +++ b/relay/ui/app.js @@ -722,7 +722,7 @@ async function loadChatHistory(peer, quiet = false) { container.innerHTML = messages.map(m => `
${escapeHtml(m.body)}
-
${formatRelTime(m.created_at)}${m.mine && m.seen ? ' ' : ''}
+
${formatChatTime(m.created_at)}${m.mine && m.seen ? ' ' : ''}
`).join(''); @@ -750,6 +750,18 @@ $('chat-input')?.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); } }); +function formatChatTime(sqliteUtc) { + if (!sqliteUtc) return ''; + const t = new Date(sqliteUtc.replace(' ', 'T') + 'Z'); + if (isNaN(t.getTime())) return sqliteUtc; + const now = new Date(); + const time = t.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + if (t.toDateString() === now.toDateString()) return time; + const yesterday = new Date(now); yesterday.setDate(yesterday.getDate() - 1); + if (t.toDateString() === yesterday.toDateString()) return 'yesterday ' + time; + return t.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + time; +} + function formatRelTime(sqliteUtc) { if (!sqliteUtc) return ''; // Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z diff --git a/src/Database.php b/src/Database.php index 2b08764..f0cead3 100644 --- a/src/Database.php +++ b/src/Database.php @@ -152,6 +152,10 @@ class Database self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_buckets_parent_id ON buckets(parent_id)'); } + if (!in_array('scratchpad', $colNames)) { + self::$pdo->exec("ALTER TABLE cards ADD COLUMN scratchpad TEXT DEFAULT NULL"); + } + // Card edit history self::$pdo->exec(' CREATE TABLE IF NOT EXISTS card_history (