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/')) {
+
+
+
+ scratchpad
+
+
+
+
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 (