- auth: when a background poll or save returns 401 (session expired while the UI is still open), the api() helpers now prompt for the password instead of letting the save fail silently. Concurrent 401s collapse onto a single dialog via a shared reauthPromise, and each caller retries its original request once after login. /api/auth/* is excluded from the retry path. Extracts promptLogin() in app.js and exposes it as window._reauth so both the core util.js api() and the plugin-SDK hyprcrd.js api() can share the flow. - tests: the "open-switches-bucket" detail test assumed buckets[0] was a plain top-level bucket, but the bar renders super-bucket parents with data-bucket-super and hides child buckets entirely, so the .bucket-tab.active[data-bucket] selector found nothing on setups with nested layouts. Test now picks two plain top-level buckets or skips. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
567 lines
35 KiB
JavaScript
567 lines
35 KiB
JavaScript
/* ══════════════════════════════════════
|
||
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 cards in batches → replace spinners with content
|
||
const batchSize = 25;
|
||
for (let i = 0; i < meta.length; i += batchSize) {
|
||
const batch = meta.slice(i, i + batchSize);
|
||
const ids = batch.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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||
|
||
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/) ──
|