Simple watchface with second-hand wobble animation, 5-min idle auto-show, W key toggle, light/dark theme colors, backdrop blur from theme settings. Grace period prevents instant dismiss. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
525 lines
33 KiB
JavaScript
525 lines
33 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 { startBackgroundSync, triggerSync, loadSettings as loadCaldavSettings, loadRelaySettings } from './modules/caldav.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') || []; renderBoard(); refreshCalendar(); };
|
||
// 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)
|
||
const cached = getCachedCards();
|
||
if (cached.length) {
|
||
state.cards = cached.map(c => ({ ...c, body: null }));
|
||
updateSortUI();
|
||
renderBoard();
|
||
}
|
||
|
||
// Phase 1: Load metadata → update board with fresh titles/tags/dates
|
||
const meta = await api('api/cards/meta');
|
||
if (!meta) return;
|
||
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'); 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();
|
||
});
|
||
|
||
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 < 4) {
|
||
pw = await showDialog({ title: 'setup', message: 'Welcome to hyprcrd. Set a password:', input: 'password', placeholder: '> password (min. 4 chars)...', okLabel: 'Set', cancelLabel: '' });
|
||
if (pw && pw.length < 4) pw = null;
|
||
}
|
||
const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, 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) {
|
||
let ok = false;
|
||
while (!ok) {
|
||
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) { ok = true; if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } }
|
||
else { /* failed login: caller will retry */ }
|
||
}
|
||
}
|
||
|
||
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: () => { const escAction = localStorage.getItem('hyprcrd-esc-action') || 'close'; if (escAction === 'exitZen' && document.getElementById('detail')?.classList.contains('zen')) { document.getElementById('detail-zen')?.click(); return; } closeDetail(); } });
|
||
registerShortcut({ key: 'Escape', priority: 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: '/', 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 cards
|
||
registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && getSelectedIds().length > 0, section: 'global', keys: '<kbd>Del</kbd>', label: 'delete selected', hidden: true, handler: async () => {
|
||
const ids = getSelectedIds();
|
||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||
const ok = await showDialog({ title: 'delete cards', message: `Delete ${ids.length} card${ids.length > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
|
||
if (!ok) return;
|
||
}
|
||
const { animateCardRemoval } = await import('./modules/board.js');
|
||
await animateCardRemoval(ids);
|
||
for (const id of ids) await api(`api/cards/${id}`, { method: 'DELETE' });
|
||
clearSelection();
|
||
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/) ──
|