/* ══════════════════════════════════════ hyprcrd — app.js (orchestrator) ══════════════════════════════════════ */ import { api, setCsrf, getCsrf } from './modules/util.js'; import { state, getGroupCards } from './modules/state.js'; import { showDialog, isDialogOpen } from './modules/dialog.js'; import './modules/theme.js'; import { loadBuckets, renderTagBar, activateBucket } from './modules/buckets.js'; import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js'; import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js'; import { openModal, closeModal, saveCard, saveOnly, openFindBar } from './modules/editor.js'; import { openSearch, closeSearch } from './modules/search.js'; import { showContextMenu } from './modules/context-menu.js'; import { undo, canUndo } from './modules/undo.js'; import './modules/tooltip.js'; import { registerShortcut, guards, renderShortcutHelp } from './modules/shortcuts.js'; import { openGallery, closeGallery } from './modules/gallery.js'; import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js'; import { openCalendar, closeCalendar, refreshCalendar } from './modules/calendar.js'; import { loadSettings as loadCaldavSettings } from './modules/caldav.js'; import { startBackgroundSync, triggerSync, loadRelaySettings } from './modules/sync.js'; import { openPluginSettings } from './modules/plugin-settings.js'; // ── Global API (for inline onclick handlers and cross-module calls) ── window._openDetail = openDetail; window._openModal = openModal; window._openGallery = openGallery; window._closeSearch = closeSearch; window._closeTasks = closeTasks; window._renderBoard = renderBoard; window._openCalendar = openCalendar; window._triggerSync = triggerSync; window._openPluginSettings = openPluginSettings; window._loadCaldavSettings = loadCaldavSettings; window._loadRelaySettings = loadRelaySettings; window._renderTagBar = renderTagBar; window._showContextMenu = showContextMenu; window._ungroupCard = ungroupCard; window._getSelectedIds = getSelectedIds; window._clearSelection = clearSelection; window._refreshTasks = refreshTasksView; window.openFindBar = openFindBar; window._reloadAll = async () => { await loadBuckets(); state.cards = await api('api/cards') || []; state.cardsLoaded = true; renderBoard(); refreshCalendar(); // Let plugins / modules react to a full card reload (detail view checks // whether the currently viewed card is now stale; collab-buckets uses it // to refresh its decorators). window.hyprcrd?.hooks?.emit?.('cards.reloaded'); }; // Exposed for plugins that need to read the shared state (e.g. collab-buckets // reads `state.buckets` in its MutationObserver to find collab_uid mapping). window.state = state; window._extractToCard = async (text) => { if (!text?.trim()) return; const lines = text.trim().split('\n'); const title = lines[0].replace(/^#+\s*/, '').replace(/^\*+/, '').replace(/^-\s*/, '').trim() || 'Extracted'; const body = text.trim(); // Inherit context from the source card (viewing or editing) const source = state.viewingCard || (state.editingId ? state.cards.find(c => c.id == state.editingId) : null); const data = { title, body, color: source?.color || '#38bdf8', position: -1 }; if (source?.bucket_id) data.bucket_id = source.bucket_id; if (source?.parent_id) data.parent_id = source.parent_id; else if (source && state.cards.some(c => c.parent_id == source.id)) data.parent_id = source.id; await api('api/cards', { method: 'POST', body: data }); await window._reloadAll(); }; // ── Data loading ── const CARDS_CACHE_KEY = 'hyprcrd-cards-cache'; function getCachedCards() { try { return JSON.parse(localStorage.getItem(CARDS_CACHE_KEY) || '[]'); } catch { return []; } } function setCachedCards(cards) { try { const slim = cards.map(c => ({ id: c.id, title: c.title, color: c.color, tags: c.tags, bucket_id: c.bucket_id, bucket_name: c.bucket_name, parent_id: c.parent_id, position: c.position, created_at: c.created_at, updated_at: c.updated_at, due_date: c.due_date, caldav_uid: c.caldav_uid, caldav_rrule: c.caldav_rrule })); localStorage.setItem(CARDS_CACHE_KEY, JSON.stringify(slim)); } catch {} } async function loadCards() { // Phase 0: Show cached cards instantly (from previous session). // A hit here is enough to flip cardsLoaded — fresh meta will // overwrite state.cards a moment later, but the spinner→content // transition already happened. const cached = getCachedCards(); if (cached.length) { state.cards = cached.map(c => ({ ...c, body: null })); state.cardsLoaded = true; updateSortUI(); renderBoard(); } // Phase 1: Load metadata → update board with fresh titles/tags/dates const meta = await api('api/cards/meta'); if (!meta) return; state.cardsLoaded = true; state.cards = meta.map(c => ({ ...c, body: null })); setCachedCards(meta); updateSortUI(); renderBoard(); // Phase 2: Load full card bodies. One fetch up to the ID cap — with // the /api/cards(?ids=) endpoint now indexed on cards.position + // created_at a single big batch is faster than many small ones, and // shorter perceived spinner time matters more than memory. Fall back // to smaller batches only if the URL would blow past ~2000 chars. const IDS_MAX_PER_FETCH = 500; for (let i = 0; i < meta.length; i += IDS_MAX_PER_FETCH) { const ids = meta.slice(i, i + IDS_MAX_PER_FETCH).map(c => c.id).join(','); const full = await api('api/cards?ids=' + ids); if (!full) continue; for (const card of full) { const idx = state.cards.findIndex(c => c.id === card.id); if (idx >= 0) state.cards[idx] = card; } renderBoard(); } } // ── Help ── const helpOverlay = document.getElementById('help'); function openHelp() { helpOverlay?.classList.add('open'); } function closeHelp() { helpOverlay?.classList.remove('open'); } document.getElementById('help-btn')?.addEventListener('click', openHelp); document.getElementById('help-close')?.addEventListener('click', closeHelp); helpOverlay?.addEventListener('click', e => { if (e.target === helpOverlay) closeHelp(); }); const shortcutsOverlay = document.getElementById('shortcuts'); function openShortcuts() { shortcutsOverlay?.classList.add('open'); } function closeShortcuts() { shortcutsOverlay?.classList.remove('open'); } document.getElementById('shortcuts-btn')?.addEventListener('click', openShortcuts); document.getElementById('shortcuts-close')?.addEventListener('click', closeShortcuts); shortcutsOverlay?.addEventListener('click', e => { if (e.target === shortcutsOverlay) closeShortcuts(); }); // ── Export / Import ── document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; }); document.getElementById('export-md-btn')?.addEventListener('click', () => { window.location.href = 'api/export-md'; }); const importFile = document.getElementById('import-file'); document.getElementById('import-btn')?.addEventListener('click', () => importFile?.click()); importFile?.addEventListener('change', async e => { const file = e.target.files?.[0]; if (!file) return; // Check if it's a selective export (merge) or ask for full replace const form = new FormData(); form.append('file', file); const mode = await showDialog({ title: 'import_data', message: 'How do you want to import?', okLabel: 'Merge (add to existing)', cancelLabel: 'Replace (wipe all)' }); // mode: true = merge, null = replace if (mode === null) { const sure = await showDialog({ title: 'replace_all', message: 'All existing data will be replaced!', danger: true, okLabel: 'Replace' }); if (!sure) { e.target.value = ''; return; } } try { const url = mode ? 'api/import?merge=1' : 'api/import'; const res = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } }); const text = await res.text(); if (!res.ok) throw new Error(text || `HTTP ${res.status}`); const data = JSON.parse(text); if (data.error) throw new Error(data.error); await loadBuckets(); state.cards = await api('api/cards'); state.cardsLoaded = true; renderBoard(); } catch (err) { await showDialog({ title: 'import failed', message: err.message || 'Unknown error', danger: true, okLabel: 'OK', cancelLabel: '' }); } e.target.value = ''; }); // ── Auth ── document.getElementById('logout-btn')?.addEventListener('click', async () => { const ok = await showDialog({ title: 'logout', message: 'Log out?', okLabel: 'Logout', danger: true }); if (!ok) return; await fetch('api/auth/logout', { method: 'POST' }); location.reload(); }); // Re-usable login prompt loop. Shared between the boot-time auth check // and the 401-driven re-auth path (session expiry while the UI is open). // Concurrent callers collapse onto a single dialog via reauthPromise so a // wave of background polls doesn't stack up N prompts. let reauthPromise = null; async function promptLogin(message = '') { if (reauthPromise) return reauthPromise; reauthPromise = (async () => { while (true) { const pw = await showDialog({ title: 'login', message, input: 'password', placeholder: '> password...', okLabel: 'Login', cancelLabel: '' }); if (!pw) continue; const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) }); const d = await r.json(); if (d.ok) { if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } return; } // wrong password — loop and re-prompt } })(); try { await reauthPromise; } finally { reauthPromise = null; } } // Expose for the api() helpers so a 401 from any fetch triggers the same // dialog + CSRF refresh flow. window._reauth = () => promptLogin('Session expired — sign in to continue.'); async function checkAuth() { const res = await fetch('api/auth/status'); const data = await res.json(); if (data.csrf) { setCsrf(data.csrf); window.hyprcrd?.util?.setCsrf?.(data.csrf); } // Refresh version-displaying spans from the live API response so the // GUI doesn't show a stale APP_VERSION when the PHP page is cached. if (data.version) { for (const el of document.querySelectorAll('[data-app-version]')) { el.textContent = 'v' + data.version; } } if (data.setup) { let pw = null; while (!pw || pw.length < 10) { pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 10 chars)...', okLabel: 'Set', cancelLabel: '' }); if (pw && pw.length < 10) pw = null; } const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': data.csrf || '' }, body: JSON.stringify({ password: pw }) }); const d = await r.json(); if (d.error) { location.reload(); return; } if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } } else if (!data.logged_in) { await promptLogin(); } loadBuckets(); loadCards(); startBackgroundSync(); } // ── Keyboard shortcuts (central registry) ── // // All document-level shortcuts are registered here via registerShortcut(). // The dispatcher in shortcuts.js handles key matching, guards, and // priority ordering. Element-specific shortcuts (editor textarea, search // input, canvas, dialog) stay on their respective DOM elements. 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: () => { 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() }); registerShortcut({ key: 'Escape', priority: 50, guard: () => !isDialogOpen() && guards.isOpen('gallery'), section: 'global', label: 'close gallery', hidden: true, handler: () => closeGallery() }); registerShortcut({ key: 'Escape', priority: 40, guard: () => !isDialogOpen() && guards.isOpen('calendar'), section: 'global', label: 'close calendar', hidden: true, handler: () => closeCalendar() }); registerShortcut({ key: 'Escape', priority: 30, guard: () => !isDialogOpen() && guards.isOpen('shortcuts'), section: 'global', label: 'close shortcuts', hidden: true, handler: () => closeShortcuts() }); registerShortcut({ key: 'Escape', priority: 20, guard: () => !isDialogOpen() && guards.isOpen('help'), section: 'global', label: 'close help', hidden: true, handler: () => closeHelp() }); // Visible Escape entry for the help screen registerShortcut({ key: 'Escape', priority: -1, guard: () => false, section: 'global', keys: 'Esc', label: 'close any overlay' }); // — Global (work everywhere unless dialog is open) — registerShortcut({ key: 'k', ctrl: true, guard: () => !isDialogOpen(), section: 'global', keys: '⌃ K', label: 'search', handler: () => { const s = document.getElementById('search'); s?.classList.contains('open') ? closeSearch() : openSearch(); } }); registerShortcut({ key: 'n', ctrl: true, guard: () => !isDialogOpen(), section: 'global', keys: '⌃ N', label: 'quick capture (in bucket)', handler: () => { const qc = document.getElementById('quick-capture'); const qci = document.getElementById('quick-capture-input'); if (qc.classList.contains('hidden')) { qc.classList.remove('hidden'); qci.focus(); } else { qc.classList.add('hidden'); qci.value = ''; } } }); registerShortcut({ key: 'z', ctrl: true, guard: () => !isDialogOpen() && !guards.isOpen('modal') && canUndo(), section: 'global', keys: '⌃ Z', label: 'undo', handler: () => undo() }); registerShortcut({ key: 'F1', guard: () => !isDialogOpen(), section: 'global', keys: 'F1', label: 'help', handler: () => helpOverlay?.classList.contains('open') ? closeHelp() : openHelp() }); registerShortcut({ key: '?', guard: () => !isDialogOpen() && guards.bodyFocused(), section: 'global', keys: '?', label: 'this screen', handler: () => shortcutsOverlay?.classList.contains('open') ? closeShortcuts() : openShortcuts() }); // — Editor (modal open) — registerShortcut({ key: 's', ctrl: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '⌃ S', label: 'save', handler: () => saveOnly() }); registerShortcut({ key: 's', ctrl: true, shift: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '⌃ ⇧ S', label: 'save + close', handler: () => saveCard() }); registerShortcut({ key: 'Enter', ctrl: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '⌃ ↵', label: 'save + close', handler: () => saveCard() }); // Editor element-level shortcuts listed for help screen only (handled by editor.js) registerShortcut({ key: 'Tab', guard: () => false, section: 'editor', keys: '⇥', label: 'indent / title→body' }); registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: 'Alt ↑ ↓', label: 'move line' }); registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: '⇧ Alt ↑ ↓', label: 'duplicate line' }); registerShortcut({ key: 'Enter', guard: () => false, section: 'editor', keys: '↵', label: 'auto-continue list' }); registerShortcut({ key: '[', guard: () => false, section: 'editor', keys: '[[', label: 'link autocomplete' }); registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '#', label: 'tag autocomplete' }); registerShortcut({ key: '?', guard: () => false, section: 'editor', keys: '?snippet', label: 'snippet expand' }); registerShortcut({ key: 'h', guard: () => false, section: 'editor', keys: '⌃ H', label: 'find + replace' }); registerShortcut({ key: 'f', guard: () => false, section: 'editor', keys: '⌃ F', label: 'find' }); registerShortcut({ key: 'H', ctrl: true, shift: true, guard: () => !isDialogOpen() && !guards.isOpen('modal'), section: 'global', keys: '⌃ ⇧ H', label: 'find + replace across cards', handler: () => openSearch({ replace: true }) }); // — Detail view — registerShortcut({ match: (e) => (e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !e.ctrlKey && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '← → in preview', label: 'group prev / next', handler: (e) => { const gc = getGroupCards(state.viewingCard); if (!gc || gc.length < 2) return; const idx = gc.findIndex(c => c.id == state.viewingCard.id); if (idx === -1) return; showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id); }}); registerShortcut({ key: 'Enter', guard: () => !isDialogOpen() && guards.isOpen('detail') && !!state.viewingCard, priority: 10, section: 'board + detail', keys: '↵ in preview', label: '→ editor', handler: () => { const card = state.viewingCard; closeDetail(); openModal(card); } }); // — Board navigation (body focus, no overlay) — registerShortcut({ key: 'a', guard: gBoard, section: 'navigation', keys: 'A', label: 'all cards', handler: () => activateBucket('all') }); registerShortcut({ key: 'f', guard: gBoard, section: 'navigation', keys: 'F', label: 'favorites', handler: () => activateBucket('favorites') }); registerShortcut({ key: 'n', guard: gBoard, section: 'navigation', keys: 'N', label: 'new card', handler: () => openModal(null) }); registerShortcut({ key: 'j', guard: gBoard, section: 'navigation', keys: 'J', label: 'today\'s journal', handler: () => { const today = new Date().toISOString().substring(0, 10); const existing = state.cards.find(c => (c.title || '').startsWith(today)); if (existing) openModal(existing); else { activateBucket('journal'); openModal(null); } }}); registerShortcut({ key: 't', guard: gBoard, section: 'navigation', keys: 'T', label: 'tag quick-pick', handler: () => openTagQuickPick() }); registerShortcut({ key: 'c', guard: gBoard, section: 'navigation', keys: 'C', label: 'calendar', handler: () => { const cal = document.getElementById('calendar'); cal?.classList.contains('open') ? closeCalendar() : openCalendar(); } }); registerShortcut({ key: 'm', guard: () => false, section: 'navigation', keys: 'M', label: 'mail (plugin)' }); registerShortcut({ key: 'w', guard: gBoard, section: 'navigation', keys: 'W', label: 'watch', handler: () => window._watchToggle?.() }); registerShortcut({ key: 'i', guard: gBoard, section: 'navigation', keys: 'I', label: 'tim (time calculator)', handler: () => window._timToggle?.() }); registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '/', label: 'search', handler: () => openSearch() }); registerShortcut({ match: (e) => e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && gBoard(), section: 'navigation', keys: '⇥ / ⇧ ⇥', label: 'cycle buckets', handler: (e) => { const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')]; if (!tiles.length) return; const curIdx = tiles.findIndex(t => { const bid = t.dataset.bucket || t.dataset.bucketSuper; if (bid == state.activeBucket) return true; if (t.dataset.bucketSuper) return state.buckets.some(b => b.parent_id == t.dataset.bucketSuper && b.id == state.activeBucket); return false; }); const dir = e.shiftKey ? -1 : 1; const next = tiles[(curIdx + dir + tiles.length) % tiles.length]; if (next) activateBucket(next.dataset.bucketSuper ? (next.dataset.superShown || next.dataset.bucketSuper) : next.dataset.bucket); }}); registerShortcut({ match: (e) => /^[1-9]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && gBoard(), section: 'navigation', keys: '1 – 9', label: 'Nth user bucket', handler: (e) => { const SPECIAL = new Set(['all', 'current', 'favorites', 'journal', 'none']); const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')].filter(t => !SPECIAL.has(t.dataset.bucket)); const tile = tiles[parseInt(e.key, 10) - 1]; if (tile) activateBucket(tile.dataset.bucketSuper ? (tile.dataset.superShown || tile.dataset.bucketSuper) : tile.dataset.bucket); }}); // — Board arrow-key card navigation + Enter — registerShortcut({ match: (e) => ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key) && gBoard(), section: 'board + detail', keys: '↑ ↓ ← →', label: 'focus card', handler: (e) => { const board = document.getElementById('board'); if (!board) return; const cards = [...board.querySelectorAll('.board-card[data-id]')]; if (!cards.length) return; const focused = board.querySelector('.card-focused'); let curIdx = focused ? cards.indexOf(focused) : -1; if (curIdx < 0) { setCardFocus(cards[0]); return; } const firstTop = cards[0].offsetTop; let cols = cards.length; for (let i = 1; i < cards.length; i++) { if (cards[i].offsetTop !== firstTop) { cols = i; break; } } let next = curIdx; if (e.key === 'ArrowRight') next = Math.min(curIdx + 1, cards.length - 1); else if (e.key === 'ArrowLeft') next = Math.max(curIdx - 1, 0); else if (e.key === 'ArrowDown') next = Math.min(curIdx + cols, cards.length - 1); else if (e.key === 'ArrowUp') next = Math.max(curIdx - cols, 0); if (next !== curIdx) setCardFocus(cards[next]); }}); registerShortcut({ key: 'Enter', guard: () => gBoard() && !!document.querySelector('#board .card-focused'), section: 'board + detail', keys: '↵', label: 'focused → preview', handler: () => { const focused = document.querySelector('#board .card-focused'); if (focused) { window._openDetail(focused.dataset.id); clearCardFocus(); } }}); // 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, 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(); }}); // — Search (help-screen-only, handled by search.js element listener) — registerShortcut({ key: 'ArrowDown', guard: () => false, section: 'search', keys: '↑ ↓', label: 'navigate results' }); registerShortcut({ key: 'Enter', guard: () => false, section: 'search', keys: '↵', label: 'open selected result' }); registerShortcut({ key: 'Escape', guard: () => false, section: 'search', keys: 'Esc', label: 'close search' }); // — Board mouse actions (help-screen-only) — registerShortcut({ key: '_click', guard: () => false, section: 'board + detail', keys: 'click', label: 'preview / split' }); registerShortcut({ key: '_dbl', guard: () => false, section: 'board + detail', keys: 'dbl-click', label: 'editor' }); registerShortcut({ key: '_rclick', guard: () => false, section: 'board + detail', keys: 'right-click', label: 'context menu' }); registerShortcut({ key: '_ctrlclick', guard: () => false, section: 'board + detail', keys: '⌃ click', label: 'multi-select' }); registerShortcut({ key: '_drag_edge', guard: () => false, section: 'board + detail', keys: 'drag → edge', label: 'reorder' }); registerShortcut({ key: '_drag_center', guard: () => false, section: 'board + detail', keys: 'drag → center', label: 'group' }); // — Canvas (help-screen-only, handled by canvas.js overlay listener) — for (const [k, lbl] of [['B','brush'],['E','eraser'],['L','line'],['R','rectangle'],['O','circle'],['F','fill'],['T','text cursor']]) { registerShortcut({ key: k.toLowerCase(), guard: () => false, section: 'canvas', keys: `${k}`, label: lbl }); } registerShortcut({ key: 'Backspace', guard: () => false, section: 'canvas', keys: '⌫', label: 'delete char' }); // — Mail (help-screen-only, handled by email.js document listener) — registerShortcut({ key: 'Backspace', guard: () => false, section: 'mail', keys: '⌫', label: 'delete selected mails' }); registerShortcut({ key: 'Backspace', guard: () => false, section: 'mail', keys: '⇧ ⌫', label: 'delete (skip confirm)' }); let focusedCardId = null; function setCardFocus(el) { clearCardFocus(); if (!el) return; el.classList.add('card-focused'); focusedCardId = el.dataset.id; el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } function clearCardFocus() { document.querySelectorAll('.card-focused').forEach(c => c.classList.remove('card-focused')); focusedCardId = null; } // Clear focus when clicking anywhere on the board (a card click opens // detail/editor and focus is no longer meaningful), or when any overlay // opens. Also re-apply after a board re-render if the focused card is // still in the DOM. document.addEventListener('click', () => { if (focusedCardId) clearCardFocus(); }); window._restoreCardFocus = function () { if (!focusedCardId) return; const el = document.querySelector(`#board .board-card[data-id="${focusedCardId}"]`); if (el) el.classList.add('card-focused'); else clearCardFocus(); }; // ── Quick Capture ── document.getElementById('quick-capture-input')?.addEventListener('keydown', async e => { if (e.key === 'Escape') { e.stopPropagation(); document.getElementById('quick-capture').classList.add('hidden'); e.target.value = ''; return; } if (e.key !== 'Enter') return; const raw = e.target.value.trim(); if (!raw) return; e.preventDefault(); // Split on first "|" — title before, body after. // Also accept newlines as a fallback (first line = title, rest = body). let cardTitle, body; const pipeIdx = raw.indexOf('|'); if (pipeIdx >= 0) { cardTitle = raw.slice(0, pipeIdx).trim(); body = raw.slice(pipeIdx + 1).trim(); } else { const lines = raw.split(/\n/); cardTitle = lines[0].trim(); body = lines.slice(1).join('\n').trim(); } if (!cardTitle) return; // No auto-tag — the card goes into the active bucket (or unsorted). // Place the card in the currently active bucket (if it's a real // user bucket). Special filters (all/current/favorites/journal/none) // don't map to a bucket_id — cards captured there stay unsorted. const VIRTUAL = ['all', 'current', 'favorites', 'journal', 'none']; const bucketId = VIRTUAL.includes(state.activeBucket) ? null : state.activeBucket; const captured = await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1, bucket_id: bucketId } }); if (captured?.id) state.recentlyCreatedIds?.add(String(captured.id)); e.target.value = ''; document.getElementById('quick-capture').classList.add('hidden'); await window._reloadAll(); // Toast const n = document.createElement('div'); n.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);'; n.innerHTML = `