fixes: scratchpad, zen ESC, chat time, tooltip safety, collab sync race
- Scratchpad: card-scoped private notes in editor (toggle vs preview), smaller font, cleared via button, stored in cards.scratchpad - Zen mode: ESC exits zen before closing detail, Z toggles zen - Chat: always show absolute time (hh:mm, yesterday, or date+time) - Backspace/Delete also deletes focused card (not just selected) - Selected + focused cards use brand accent (not accent2) - Tooltip: guard against non-element targets - Collab sync: skip poll while editor is open, 2s push debounce - Rrule test: only check geburtstag tag, not title format Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
2169c1de1b
commit
7e2b130434
11 files changed
+92
-30
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.4.0');
|
define('APP_VERSION', '1.4.1');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
@@ -494,9 +494,9 @@ body::after {
|
|||||||
.card-glow:hover { box-shadow: var(--ui-shadow-card), var(--card-stack-shadow), 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
|
.card-glow:hover { box-shadow: var(--ui-shadow-card), var(--card-stack-shadow), 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
|
||||||
.card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; }
|
.card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; }
|
||||||
.card-focused {
|
.card-focused {
|
||||||
outline: 2px solid var(--t-accent2) !important;
|
outline: 2px solid var(--t-accent) !important;
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
box-shadow: 0 0 0 1px var(--t-accent2), 0 0 16px -4px color-mix(in srgb, var(--t-accent2) 40%, transparent) !important;
|
box-shadow: 0 0 0 1px var(--t-accent), 0 0 16px -4px color-mix(in srgb, var(--t-accent) 40%, transparent) !important;
|
||||||
}
|
}
|
||||||
.card-select-check {
|
.card-select-check {
|
||||||
position: absolute; top: 6px; left: 6px; width: 18px; height: 18px;
|
position: absolute; top: 6px; left: 6px; width: 18px; height: 18px;
|
||||||
|
|||||||
+10
-1
@@ -318,7 +318,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$fields = []; $params = ['id' => $m[1]];
|
$fields = []; $params = ['id' => $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($f, $input)) { $fields[] = "$f = :$f"; $params[$f] = $input[$f]; }
|
||||||
}
|
}
|
||||||
if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); }
|
if (array_key_exists('body', $input)) { $fields[] = 'tags = :tags'; $params['tags'] = extractTags($input['body']); }
|
||||||
@@ -2236,6 +2236,7 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" data-tip="line numbers"><i class="ph ph-hash text-sm"></i></button>
|
<button id="toggle-lines" class="toolbar-btn flex items-center gap-1.5 px-2" data-tip="line numbers"><i class="ph ph-hash text-sm"></i></button>
|
||||||
<button id="toggle-preview" class="toolbar-btn flex items-center gap-1.5 px-2"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[9px]">Preview</span></button>
|
<button id="toggle-preview" class="toolbar-btn flex items-center gap-1.5 px-2"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[9px]">Preview</span></button>
|
||||||
|
<button id="toggle-scratchpad" class="toolbar-btn flex items-center gap-1.5 px-2" data-help="Scratchpad\nPrivate notes about this card.\nSaved separately, not part of the content."><i class="ph ph-notepad text-sm"></i><span class="font-pixel text-[9px]">Scratch</span></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 min-h-0 overflow-hidden">
|
<div class="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div>
|
<div id="editor-gutter" class="hidden bg-t-bg text-t-dim text-right font-mono text-sm select-none shrink-0 border-r border-t-border" style="width:3.5em; padding:1rem 0.25rem 1rem 0.5rem; overflow-y:scroll; scrollbar-width:none; -ms-overflow-style:none;"></div>
|
||||||
@@ -2264,6 +2265,14 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
</div>
|
</div>
|
||||||
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
|
<div id="editor-divider" class="hidden shrink-0" style="width:11px;cursor:col-resize;position:relative;z-index:3;margin:0 -5px;"><div style="position:absolute;left:5px;top:0;bottom:0;width:1px;background:var(--t-border);"></div><div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:4px;height:48px;border-radius:2px;background:var(--t-muted);opacity:0.08;transition:opacity 0.2s;"></div></div>
|
||||||
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
|
<div id="editor-preview" class="bg-t-bg p-4 overflow-y-auto hidden" style="flex:1 1 0%;min-width:0;"><div id="preview-content" class="prose text-sm text-t-text"></div></div>
|
||||||
|
<div id="editor-scratchpad" class="bg-t-bg flex flex-col hidden" style="flex:1 1 0%;min-width:0;">
|
||||||
|
<div class="flex items-center gap-2 px-3 py-1.5 border-b border-t-border shrink-0">
|
||||||
|
<i class="ph ph-notepad text-xs text-t-dim"></i>
|
||||||
|
<span class="font-pixel text-[9px] text-t-dim flex-1">scratchpad</span>
|
||||||
|
<button id="scratchpad-clear" class="font-pixel text-[8px] text-t-muted hover:text-t-danger transition-colors">clear</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="scratchpad-body" placeholder="private notes..." class="flex-1 text-t-muted font-mono placeholder:text-t-dim border-none overflow-y-auto bg-transparent" style="padding:1rem;font-size:11px;line-height:1.625;resize:none;outline:none;"></textarea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-5
@@ -214,7 +214,8 @@ const { board: gBoard } = guards;
|
|||||||
|
|
||||||
// — Escape chain (highest priority first) —
|
// — 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: 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: '<kbd>Z</kbd>', 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: 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: 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() });
|
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');
|
const focused = document.querySelector('#board .card-focused');
|
||||||
if (focused) { window._openDetail(focused.dataset.id); clearCardFocus(); }
|
if (focused) { window._openDetail(focused.dataset.id); clearCardFocus(); }
|
||||||
}});
|
}});
|
||||||
// Delete selected cards
|
// Delete selected or focused card
|
||||||
registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && getSelectedIds().length > 0, section: 'global', keys: '<kbd>Del</kbd>', label: 'delete selected', hidden: true, handler: async () => {
|
registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && (getSelectedIds().length > 0 || !!document.querySelector('#board .card-focused')), section: 'global', keys: '<kbd>⌫</kbd>', label: 'delete selected / focused', hidden: true, handler: async () => {
|
||||||
const ids = getSelectedIds();
|
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') {
|
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' });
|
const ok = await showDialog({ title: 'delete cards', message: `Delete ${ids.length} card${ids.length > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
const { animateCardRemoval } = await import('./modules/board.js');
|
const { animateCardRemoval, clearCardFocus } = await import('./modules/board.js');
|
||||||
await animateCardRemoval(ids);
|
await animateCardRemoval(ids);
|
||||||
for (const id of ids) await api(`api/cards/${id}`, { method: 'DELETE' });
|
for (const id of ids) await api(`api/cards/${id}`, { method: 'DELETE' });
|
||||||
clearSelection();
|
clearSelection();
|
||||||
|
clearCardFocus();
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
}});
|
}});
|
||||||
|
|
||||||
|
|||||||
+36
-14
@@ -189,12 +189,8 @@ export function openModal(card = null) {
|
|||||||
selectColor(card ? card.color : '#38bdf8');
|
selectColor(card ? card.color : '#38bdf8');
|
||||||
setDueDate(card?.due_date || '');
|
setDueDate(card?.due_date || '');
|
||||||
updateDoneToggle();
|
updateDoneToggle();
|
||||||
state.previewVisible = true;
|
if (scratchpadBody) scratchpadBody.value = card?.scratchpad || '';
|
||||||
preview.classList.remove('hidden');
|
showSidePanel('preview');
|
||||||
editorDivider?.classList.remove('hidden');
|
|
||||||
$('toggle-preview').classList.add('text-t-accent');
|
|
||||||
applyEditorSplit();
|
|
||||||
updatePreview();
|
|
||||||
modal.classList.add('open');
|
modal.classList.add('open');
|
||||||
if (linesVisible) updateGutter();
|
if (linesVisible) updateGutter();
|
||||||
currentLine = -1;
|
currentLine = -1;
|
||||||
@@ -396,7 +392,8 @@ function updatePreview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doSave(close, { animate = true } = {}) {
|
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;
|
if (!data.title.trim()) return;
|
||||||
const returnToGallery = state.returnToGalleryAfterEdit;
|
const returnToGallery = state.returnToGalleryAfterEdit;
|
||||||
if (state.editingId) {
|
if (state.editingId) {
|
||||||
@@ -536,18 +533,43 @@ function applyEditorSplit() {
|
|||||||
preview.style.flex = `${100 - editorSplit} 1 0`;
|
preview.style.flex = `${100 - editorSplit} 1 0`;
|
||||||
}
|
}
|
||||||
|
|
||||||
$('toggle-preview')?.addEventListener('click', () => {
|
const scratchpadPanel = $('editor-scratchpad');
|
||||||
state.previewVisible = !state.previewVisible;
|
const scratchpadBody = $('scratchpad-body');
|
||||||
preview.classList.toggle('hidden', !state.previewVisible);
|
|
||||||
editorDivider?.classList.toggle('hidden', !state.previewVisible);
|
function showSidePanel(which) {
|
||||||
$('toggle-preview').classList.toggle('text-t-accent', state.previewVisible);
|
const showPreview = which === 'preview';
|
||||||
if (state.previewVisible) {
|
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();
|
applyEditorSplit();
|
||||||
|
if (showScratch) scratchpadPanel.style.flex = preview.style.flex || '1 1 0%';
|
||||||
} else {
|
} else {
|
||||||
if (editorPane) editorPane.style.flex = '1 1 0%';
|
if (editorPane) editorPane.style.flex = '1 1 0%';
|
||||||
preview.style.flex = '';
|
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) {
|
if (editorDivider) {
|
||||||
|
|||||||
@@ -71,14 +71,16 @@ function hide() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onEnter(e) {
|
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;
|
if (!target) return;
|
||||||
clearTimeout(hideTimer);
|
clearTimeout(hideTimer);
|
||||||
showTimer = setTimeout(() => show(target), SHOW_DELAY);
|
showTimer = setTimeout(() => show(target), SHOW_DELAY);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onLeave(e) {
|
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;
|
if (!target) return;
|
||||||
clearTimeout(showTimer);
|
clearTimeout(showTimer);
|
||||||
hideTimer = setTimeout(hide, HIDE_DELAY);
|
hideTimer = setTimeout(hide, HIDE_DELAY);
|
||||||
|
|||||||
@@ -436,7 +436,8 @@ function collabSyncCards(PDO $db): array {
|
|||||||
FROM cards c
|
FROM cards c
|
||||||
INNER JOIN buckets b ON b.id = c.bucket_id AND b.collab_uid IS NOT NULL
|
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
|
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();
|
")->fetchAll();
|
||||||
|
|
||||||
$assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id');
|
$assignUid = $db->prepare('UPDATE cards SET relay_uid = :u WHERE id = :id');
|
||||||
|
|||||||
@@ -58,8 +58,15 @@ async function loadCollabMeta() {
|
|||||||
} catch (err) { /* silent; relay may be offline */ }
|
} catch (err) { /* silent; relay may be offline */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pollLocked = false;
|
||||||
|
|
||||||
async function pollOnce() {
|
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 {
|
try {
|
||||||
|
pollLocked = true;
|
||||||
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
|
const res = await hyprcrd.api('api/collab-buckets/poll', { method: 'POST' });
|
||||||
if (!res?.ok) return;
|
if (!res?.ok) return;
|
||||||
if (res.self) window._collabSelf = res.self;
|
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 : [];
|
const by = Array.isArray(res.card_stats?.blocked_by) ? res.card_stats.blocked_by : [];
|
||||||
showBlockedToast(by);
|
showBlockedToast(by);
|
||||||
}
|
}
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ } finally { pollLocked = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function showConflictToast(n) {
|
function showConflictToast(n) {
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ describe('RRULE: Data Model', () => {
|
|||||||
const birthdays = state.cards.filter(c => (c.tags || '').includes('geburtstag'));
|
const birthdays = state.cards.filter(c => (c.tags || '').includes('geburtstag'));
|
||||||
for (const b of birthdays) {
|
for (const b of birthdays) {
|
||||||
expect(b.tags).toContain('geburtstag');
|
expect(b.tags).toContain('geburtstag');
|
||||||
// Birthday titles start with date
|
|
||||||
expect(b.title).toMatch(/^\d{4}-\d{2}-\d{2}/);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -722,7 +722,7 @@ async function loadChatHistory(peer, quiet = false) {
|
|||||||
container.innerHTML = messages.map(m => `
|
container.innerHTML = messages.map(m => `
|
||||||
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
|
<div class="chat-msg ${m.mine ? 'mine' : 'theirs'}">
|
||||||
<div>${escapeHtml(m.body)}</div>
|
<div>${escapeHtml(m.body)}</div>
|
||||||
<div class="msg-time">${formatRelTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
|
<div class="msg-time">${formatChatTime(m.created_at)}${m.mine && m.seen ? ' <i class="ph ph-checks"></i>' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
@@ -750,6 +750,18 @@ $('chat-input')?.addEventListener('keydown', e => {
|
|||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChatMessage(); }
|
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) {
|
function formatRelTime(sqliteUtc) {
|
||||||
if (!sqliteUtc) return '';
|
if (!sqliteUtc) return '';
|
||||||
// Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z
|
// Parse SQLite's "YYYY-MM-DD HH:MM:SS" as UTC by appending Z
|
||||||
|
|||||||
@@ -152,6 +152,10 @@ class Database
|
|||||||
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_buckets_parent_id ON buckets(parent_id)');
|
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
|
// Card edit history
|
||||||
self::$pdo->exec('
|
self::$pdo->exec('
|
||||||
CREATE TABLE IF NOT EXISTS card_history (
|
CREATE TABLE IF NOT EXISTS card_history (
|
||||||
|
|||||||
Reference in new issue
Block a user