Files
hyprcrd/public/js/app.js
T
thandClaude Opus 4.7 2d58cd993e v2.0.10 — snappier boot + non-blocking relay push on save
DB
- Six indices on the cards table (position+created_at, bucket_id,
  parent_id, updated_at, caldav_uid, relay_uid). The /api/cards
  ORDER BY position was a full-table scan + sort on every board
  reload; now it's an index walk.
- PRAGMA synchronous=NORMAL (safe under WAL) + temp_store=MEMORY.

Save path
- pushCardToRelayRead and deleteCardFromRelayRead now run AFTER the
  response is flushed via fastcgi_finish_request (falls back to
  ignore_user_abort + flush on non-FPM setups). The user's save no
  longer blocks on the relay round-trip.
- cURL timeouts dropped from 10s/30s to 5s/10s, plus a 2s
  connect-timeout. Worst-case on a dead relay goes from ~160s down
  to ~15s — and now that worst case is in the background anyway.

Client
- loadCards phase-2 batches bumped from 25 to 500 cards per fetch.
  For 300 cards that's 1 HTTP trip instead of 12 — spinner wall time
  drops from ~3s to ~0.25s.
- renderBoard() is now requestAnimationFrame-coalesced. The boot
  sequence calls renderBoard from cache paint, meta paint, body
  paint, and the cards.reloaded plugin hook; they used to each
  trigger an innerHTML swap + layout pass. Now they collapse into
  one actual render per frame. Callers that really need a sync
  render (rare) can use the new renderBoardSync().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 20:41:48 +02:00

570 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ══════════════════════════════════════
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: '<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: 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: '<kbd>Esc</kbd>', label: 'close any overlay' });
// — Global (work everywhere unless dialog is open) —
registerShortcut({ key: 'k', ctrl: true, guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>⌃</kbd> <kbd>K</kbd>', label: 'search', handler: () => { const s = document.getElementById('search'); s?.classList.contains('open') ? closeSearch() : openSearch(); } });
registerShortcut({ key: 'n', ctrl: true, guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>⌃</kbd> <kbd>N</kbd>', 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: '<kbd>⌃</kbd> <kbd>Z</kbd>', label: 'undo', handler: () => undo() });
registerShortcut({ key: 'F1', guard: () => !isDialogOpen(), section: 'global', keys: '<kbd>F1</kbd>', label: 'help', handler: () => helpOverlay?.classList.contains('open') ? closeHelp() : openHelp() });
registerShortcut({ key: '?', guard: () => !isDialogOpen() && guards.bodyFocused(), section: 'global', keys: '<kbd>?</kbd>', 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: '<kbd>⌃</kbd> <kbd>S</kbd>', label: 'save', handler: () => saveOnly() });
registerShortcut({ key: 's', ctrl: true, shift: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '<kbd>⌃</kbd> <kbd>⇧</kbd> <kbd>S</kbd>', label: 'save + close', handler: () => saveCard() });
registerShortcut({ key: 'Enter', ctrl: true, guard: () => !isDialogOpen() && guards.isOpen('modal'), section: 'editor', keys: '<kbd>⌃</kbd> <kbd>↵</kbd>', 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: '<kbd>⇥</kbd>', label: 'indent / title→body' });
registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: '<kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd>', label: 'move line' });
registerShortcut({ key: 'ArrowUp', guard: () => false, section: 'editor', keys: '<kbd>⇧</kbd> <kbd>Alt</kbd> <kbd>↑</kbd> <kbd>↓</kbd>', label: 'duplicate line' });
registerShortcut({ key: 'Enter', guard: () => false, section: 'editor', keys: '<kbd>↵</kbd>', label: 'auto-continue list' });
registerShortcut({ key: '[', guard: () => false, section: 'editor', keys: '<kbd>[</kbd><kbd>[</kbd>', label: 'link autocomplete' });
registerShortcut({ key: '#', guard: () => false, section: 'editor', keys: '<kbd>#</kbd>', label: 'tag autocomplete' });
registerShortcut({ key: '?', guard: () => false, section: 'editor', keys: '<kbd>?</kbd><em>snippet</em>', label: 'snippet expand' });
registerShortcut({ key: 'h', guard: () => false, section: 'editor', keys: '<kbd>⌃</kbd> <kbd>H</kbd>', label: 'find + replace' });
registerShortcut({ key: 'f', guard: () => false, section: 'editor', keys: '<kbd>⌃</kbd> <kbd>F</kbd>', label: 'find' });
registerShortcut({ key: 'H', ctrl: true, shift: true, guard: () => !isDialogOpen() && !guards.isOpen('modal'), section: 'global', keys: '<kbd>⌃</kbd> <kbd>⇧</kbd> <kbd>H</kbd>', 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: '<kbd>←</kbd> <kbd>→</kbd> <span class="mouse">in preview</span>', 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: '<kbd>↵</kbd> <span class="mouse">in preview</span>', label: '→ editor', handler: () => { const card = state.viewingCard; closeDetail(); openModal(card); } });
// — Board navigation (body focus, no overlay) —
registerShortcut({ key: 'a', guard: gBoard, section: 'navigation', keys: '<kbd>A</kbd>', label: 'all cards', handler: () => activateBucket('all') });
registerShortcut({ key: 'f', guard: gBoard, section: 'navigation', keys: '<kbd>F</kbd>', label: 'favorites', handler: () => activateBucket('favorites') });
registerShortcut({ key: 'n', guard: gBoard, section: 'navigation', keys: '<kbd>N</kbd>', label: 'new card', handler: () => openModal(null) });
registerShortcut({ key: 'j', guard: gBoard, section: 'navigation', keys: '<kbd>J</kbd>', 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: '<kbd>T</kbd>', label: 'tag quick-pick', handler: () => openTagQuickPick() });
registerShortcut({ key: 'c', guard: gBoard, section: 'navigation', keys: '<kbd>C</kbd>', label: 'calendar', handler: () => { const cal = document.getElementById('calendar'); cal?.classList.contains('open') ? closeCalendar() : openCalendar(); } });
registerShortcut({ key: 'm', guard: () => false, section: 'navigation', keys: '<kbd>M</kbd>', label: 'mail (plugin)' });
registerShortcut({ key: 'w', guard: gBoard, section: 'navigation', keys: '<kbd>W</kbd>', label: 'watch', handler: () => window._watchToggle?.() });
registerShortcut({ key: 'i', guard: gBoard, section: 'navigation', keys: '<kbd>I</kbd>', label: 'tim (time calculator)', handler: () => window._timToggle?.() });
registerShortcut({ key: '/', guard: gBoard, section: 'navigation', keys: '<kbd>/</kbd>', label: 'search', handler: () => openSearch() });
registerShortcut({ match: (e) => e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && gBoard(), section: 'navigation', keys: '<kbd>⇥</kbd> / <kbd>⇧</kbd> <kbd>⇥</kbd>', 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: '<kbd>1</kbd> – <kbd>9</kbd>', 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: '<kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd>', 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: '<kbd>↵</kbd>', 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: '<kbd>⌫</kbd>', 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: '<kbd>↑</kbd> <kbd>↓</kbd>', label: 'navigate results' });
registerShortcut({ key: 'Enter', guard: () => false, section: 'search', keys: '<kbd>↵</kbd>', label: 'open selected result' });
registerShortcut({ key: 'Escape', guard: () => false, section: 'search', keys: '<kbd>Esc</kbd>', label: 'close search' });
// — Board mouse actions (help-screen-only) —
registerShortcut({ key: '_click', guard: () => false, section: 'board + detail', keys: '<span class="mouse">click</span>', label: 'preview / split' });
registerShortcut({ key: '_dbl', guard: () => false, section: 'board + detail', keys: '<span class="mouse">dbl-click</span>', label: 'editor' });
registerShortcut({ key: '_rclick', guard: () => false, section: 'board + detail', keys: '<span class="mouse">right-click</span>', label: 'context menu' });
registerShortcut({ key: '_ctrlclick', guard: () => false, section: 'board + detail', keys: '<kbd>⌃</kbd> <span class="mouse">click</span>', label: 'multi-select' });
registerShortcut({ key: '_drag_edge', guard: () => false, section: 'board + detail', keys: '<span class="mouse">drag → edge</span>', label: 'reorder' });
registerShortcut({ key: '_drag_center', guard: () => false, section: 'board + detail', keys: '<span class="mouse">drag → center</span>', 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: `<kbd>${k}</kbd>`, label: lbl });
}
registerShortcut({ key: 'Backspace', guard: () => false, section: 'canvas', keys: '<kbd>⌫</kbd>', label: 'delete char' });
// — Mail (help-screen-only, handled by email.js document listener) —
registerShortcut({ key: 'Backspace', guard: () => false, section: 'mail', keys: '<kbd>⌫</kbd>', label: 'delete selected mails' });
registerShortcut({ key: 'Backspace', guard: () => false, section: 'mail', keys: '<kbd>⇧</kbd> <kbd>⌫</kbd>', 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 = `<div style="font-family:var(--t-font-pixel);font-size:10px;color:var(--t-accent);display:flex;align-items:center;gap:6px;"><i class="ph ph-lightning"></i>captured: ${cardTitle.substring(0, 40)}</div>`;
document.body.appendChild(n);
setTimeout(() => { n.style.transition = 'opacity .3s'; n.style.opacity = '0'; setTimeout(() => n.remove(), 350); }, 2500);
});
// ── Tag quick-pick (bare "t" hotkey on the board) ──
/** Show a small popover listing every tag found on any card. Typing
* filters; arrow keys + enter pick; clicking outside or ESC closes.
* Picking a tag sets state.activeTags = [tag] and re-renders the board. */
function openTagQuickPick() {
const existing = document.getElementById('tag-quickpick');
if (existing) { existing.remove(); return; }
const tagSet = new Set();
for (const c of state.cards) {
for (const t of (c.tags || '').split(',').filter(Boolean)) tagSet.add(t);
}
const allTags = [...tagSet].sort();
if (!allTags.length) return;
const overlay = document.createElement('div');
overlay.id = 'tag-quickpick';
overlay.style.cssText = 'position:fixed;inset:0;z-index:120;background:var(--t-overlay,rgba(0,0,0,0.55));display:flex;align-items:flex-start;justify-content:center;padding:80px 16px 16px;backdrop-filter:blur(var(--ui-blur-overlay,0));';
const box = document.createElement('div');
box.style.cssText = 'background:var(--t-surface);border:1px solid var(--t-border);border-radius:var(--ui-radius-modal);box-shadow:var(--ui-shadow-modal);width:100%;max-width:380px;max-height:60vh;display:flex;flex-direction:column;overflow:hidden;';
box.innerHTML = `
<div style="padding:10px 12px;border-bottom:1px solid var(--t-border);display:flex;align-items:center;gap:8px;">
<i class="ph ph-funnel text-t-accent"></i>
<input id="tag-quickpick-input" type="text" placeholder="filter tags…"
style="flex:1;background:var(--t-bg);border:1px solid var(--t-border);border-radius:var(--ui-radius-input);color:var(--t-bright);padding:6px 10px;font-size:12px;font-family:var(--t-font-content,'JetBrains Mono',monospace);outline:none;">
</div>
<div id="tag-quickpick-list" style="flex:1;overflow-y:auto;padding:4px 0;"></div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
const input = box.querySelector('#tag-quickpick-input');
const list = box.querySelector('#tag-quickpick-list');
let filtered = allTags;
let active = 0;
function escHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function renderList() {
if (!filtered.length) {
list.innerHTML = '<div style="padding:14px 12px;text-align:center;font-size:11px;color:var(--t-dim);font-style:italic;">no tags match</div>';
return;
}
list.innerHTML = filtered.map((t, i) => `
<button class="tag-quickpick-row" data-tag="${escHtml(t)}" data-idx="${i}"
style="display:block;width:100%;text-align:left;padding:6px 14px;font-size:11px;font-family:var(--t-font-pixel,'Silkscreen',cursive);color:${i === active ? 'var(--t-bright)' : 'var(--t-text)'};background:${i === active ? 'var(--t-raised)' : 'transparent'};border:none;cursor:pointer;">
#${escHtml(t)}
</button>
`).join('');
list.querySelectorAll('[data-tag]').forEach(btn => {
btn.addEventListener('mouseenter', () => { active = parseInt(btn.dataset.idx, 10); renderList(); });
btn.addEventListener('click', () => pick(btn.dataset.tag));
});
const activeEl = list.querySelector(`[data-idx="${active}"]`);
activeEl?.scrollIntoView({ block: 'nearest' });
}
function pick(tag) {
state.activeTags = [tag];
close();
window._renderTagBar?.();
window._renderBoard?.();
}
function close() {
overlay.remove();
document.removeEventListener('keydown', onKey, true);
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); }
else if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1); renderList(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0); renderList(); }
else if (e.key === 'Enter') { e.preventDefault(); if (filtered[active]) pick(filtered[active]); }
}
input.addEventListener('input', () => {
const f = input.value.toLowerCase().replace(/^#/, '');
filtered = f ? allTags.filter(t => t.toLowerCase().includes(f)) : allTags;
active = 0;
renderList();
});
overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
// Capture-phase keydown so ESC etc. don't bubble back to the rest of
// the page handlers (search, board) while the picker is open.
document.addEventListener('keydown', onKey, true);
renderList();
setTimeout(() => input.focus(), 0);
}
// ── Boot ──
renderShortcutHelp();
checkAuth();
// ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──