Files
hyprcrd/public/js/app.js
T
thandClaude Opus 4.6 7c113fd5ab shortcuts: board arrow-nav, enter→preview→edit, help screen polish, n/a/f/j hotkeys
Board arrow-key card navigation (public/js/app.js):
* ↑/↓/←/→ on the board (body focus, no overlay) move a single-card
  focus cursor (.card-focused, accent2 outline) through the visible
  card grid. Column count is computed live from the first row's
  offsetTop so it tracks the CSS grid columns at any viewport width.
* First arrow press focuses the first card; subsequent presses move
  in the grid. Focused card scrolls into view (smooth, nearest).
* Enter on a focused card → openDetail (preview). Focus cleared.
* Enter in detail view → closeDetail + openModal (editor). Card ref
  is captured before closeDetail nulls state.viewingCard.
* Click anywhere → focus cleared. Re-renders restore focus via
  window._restoreCardFocus() called from board.js renderBoard().

CSS (public/css/style.css):
* .card-focused: accent2 outline + glow, visually distinct from
  .card-selected (accent, multi-select) and .preview-active.
* .shortcut-table kbd keycaps: JetBrains Mono font (not pixel),
  10px, 3D raised look with gradient + bottom border + inner
  highlight + shadow.
* Responsive shortcut grid: 3 cols > 900px, 2 cols > 640px, 1 col.

New bare-key shortcuts (public/js/app.js):
* n → openModal(null) (new card, same as the + button)
* a → activateBucket('all')
* f → activateBucket('favorites')
* j → open today's journal card (find by YYYY-MM-DD prefix in
  state.cards), or create a new one (switch to journal view, open
  empty editor which auto-fills the date title)

Ctrl+N quick capture (public/js/app.js):
* Now sends bucket_id: state.activeBucket on the POST (unless the
  active view is a virtual filter like all/current/etc., in which
  case bucket_id stays null). Cards captured while viewing a user
  bucket land directly in that bucket.

Shortcuts help screen (public/index.php):
* max-w-4xl, 3-column grid layout.
* Complete shortcut inventory across all modules:
  - Navigation: a/f/j/n/t/c/m, 1-9, Tab/Shift+Tab, /
  - Global: Ctrl+K/N/Z, Esc, F1, ?, Del
  - Editor: Ctrl+S, Ctrl+Shift+S, Ctrl+Enter, Tab, Alt+↑↓,
    Shift+Alt+↑↓, Enter (list continue), [[ (link ac), # (tag ac),
    ?snippet
  - Search: ↑↓ navigate, Enter open, Esc close
  - Board+Detail: arrows focus, Enter→preview, Enter→editor,
    ←→ group nav, click/dbl/right/ctrl+click, drag edge/center
  - Canvas: b/e/l/r/o/f/t tool shortcuts, ⌫ delete
  - Mail: ⌫ delete selected, Shift+⌫ skip confirm
* kbd elements use normal font (JetBrains Mono) instead of pixel.
* Help overlay's old shortcuts section replaced with a one-liner
  pointing to ? for the full reference.
* #shortcuts added to runner.js closeAll() list.

chore: bump version to 0.9.12

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:32:31 +02:00

594 lines
31 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 } 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 { 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._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(); });
document.addEventListener('keydown', e => {
if (e.key === 'F1') { e.preventDefault(); helpOverlay?.classList.contains('open') ? closeHelp() : openHelp(); }
if (e.key === '?' && document.activeElement === document.body) { e.preventDefault(); shortcutsOverlay?.classList.contains('open') ? closeShortcuts() : openShortcuts(); }
});
// ── 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 data = JSON.parse(await res.text());
if (data.error) throw new Error(data.error);
await loadBuckets(); state.cards = await api('api/cards'); renderBoard();
} catch {}
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();
}
// ── Global keyboard ──
document.addEventListener('keydown', e => {
const modal = document.getElementById('modal');
const detail = document.getElementById('detail');
const search = document.getElementById('search');
const tasks = document.getElementById('tasks');
if (isDialogOpen()) return;
if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); search.classList.contains('open') ? closeSearch() : openSearch(); return; }
if (e.key === 'n' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault();
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 = ''; }
return;
}
if (e.key === 'Escape') {
const qc = document.getElementById('quick-capture');
if (!qc.classList.contains('hidden')) { qc.classList.add('hidden'); document.getElementById('quick-capture-input').value = ''; return; }
if (detail.classList.contains('open')) {
// If the user chose "exit zen first", ESC leaves zen mode before closing the card.
const escAction = localStorage.getItem('hyprcrd-esc-action') || 'close';
if (escAction === 'exitZen' && detail.classList.contains('zen')) {
document.getElementById('detail-zen')?.click();
return;
}
closeDetail();
}
else if (modal.classList.contains('open')) closeModal();
else if (search.classList.contains('open')) closeSearch();
else if (tasks?.classList.contains('open')) closeTasks();
else if (document.getElementById('gallery')?.classList.contains('open')) closeGallery();
else if (document.getElementById('calendar')?.classList.contains('open')) closeCalendar();
else if (shortcutsOverlay?.classList.contains('open')) closeShortcuts();
return;
}
if (e.key === 's' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); e.shiftKey ? saveCard() : saveOnly(); return; }
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && modal.classList.contains('open')) { e.preventDefault(); saveCard(); return; }
if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !modal.classList.contains('open') && canUndo()) { e.preventDefault(); undo(); return; }
if (e.key === '/' && ![modal, detail, search, tasks].some(el => el?.classList.contains('open')) && document.activeElement === document.body) { e.preventDefault(); openSearch(); }
// Bare "n" → open editor for a new card (same as clicking the + button).
if (e.key === 'n' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar'), document.getElementById('shortcuts')];
if (blockers.some(el => el?.classList.contains('open'))) return;
e.preventDefault();
openModal(null);
return;
}
// Bare "a" → bucket all, "f" → bucket favorites. Same guards as the
// other bare-key shortcuts.
if ((e.key === 'a' || e.key === 'f') && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
if (blockers.some(el => el?.classList.contains('open'))) return;
e.preventDefault();
activateBucket(e.key === 'a' ? 'all' : 'favorites');
return;
}
// Bare "j" → open today's journal card (or create one if it doesn't
// exist). Journal cards have a YYYY-MM-DD title prefix.
if (e.key === 'j' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
if (blockers.some(el => el?.classList.contains('open'))) return;
e.preventDefault();
const today = new Date().toISOString().substring(0, 10);
const existing = state.cards.find(c => (c.title || '').startsWith(today));
if (existing) {
openModal(existing);
} else {
// Switch to journal view so openModal picks up the journal
// context (auto-fills the date title).
activateBucket('journal');
openModal(null);
}
return;
}
// Bare "c" toggles the calendar from the board. Same guards as "/":
// no modifier, focus on <body> (so it doesn't intercept text typing),
// and no full-screen overlay already in front.
if (e.key === 'c' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
const calendar = document.getElementById('calendar');
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay')];
if (blockers.some(el => el?.classList.contains('open'))) return;
e.preventDefault();
if (calendar?.classList.contains('open')) closeCalendar();
else openCalendar();
return;
}
// Bare "t" opens the tag quick-pick on the board. Same focus +
// overlay guards as "c". Picking a tag sets state.activeTags = [tag].
if (e.key === 't' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
if (blockers.some(el => el?.classList.contains('open'))) return;
e.preventDefault();
openTagQuickPick();
return;
}
// Tab / Shift+Tab cycles through ALL bucket tiles in bar order
// (specials + user buckets + super-buckets). Wraps around at both
// ends. Same focus + overlay guards as the other bare-key shortcuts.
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && document.activeElement === document.body) {
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
if (blockers.some(el => el?.classList.contains('open'))) return;
const tiles = [...document.querySelectorAll('#bucket-bar .bucket-tab[data-bucket], #bucket-bar .bucket-tab[data-bucket-super]')];
if (!tiles.length) return;
// Find which tile is currently active
const curIdx = tiles.findIndex(t => {
const bid = t.dataset.bucket || t.dataset.bucketSuper;
if (bid == state.activeBucket) return true;
// Super-bucket: active if any child matches
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 nextIdx = (curIdx + dir + tiles.length) % tiles.length;
const next = tiles[nextIdx];
if (next) {
e.preventDefault();
const target = next.dataset.bucketSuper
? (next.dataset.superShown || next.dataset.bucketSuper)
: next.dataset.bucket;
activateBucket(target);
}
return;
}
// Bare "1"–"9" picks the Nth user bucket in the bar — fixed filters
// (all/current/favorites/journal/none) are skipped, super-buckets
// count as one slot and activate via activateBucket() so the
// dropdown popover is NOT triggered.
if (/^[1-9]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && document.activeElement === document.body) {
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar')];
if (blockers.some(el => el?.classList.contains('open'))) return;
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 idx = parseInt(e.key, 10) - 1;
const tile = tiles[idx];
if (tile) {
e.preventDefault();
// Super-bucket: activate the currently shown member (parent or
// active child) directly instead of clicking, which would open
// the dropdown popover. Regular bucket: just take its data-bucket.
const target = tile.dataset.bucketSuper ? (tile.dataset.superShown || tile.dataset.bucketSuper) : tile.dataset.bucket;
activateBucket(target);
}
return;
}
// Arrow keys navigate group in detail view
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && detail.classList.contains('open') && state.viewingCard) {
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;
e.preventDefault();
showDetailCard((e.key === 'ArrowRight' ? gc[(idx + 1) % gc.length] : gc[(idx - 1 + gc.length) % gc.length]).id);
}
// Enter in detail view → close preview, then open editor
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && detail.classList.contains('open') && state.viewingCard) {
e.preventDefault();
const card = state.viewingCard;
closeDetail();
openModal(card);
return;
}
// ── Board arrow-key card navigation ──
// Arrow keys select a card on the board (different from Ctrl+click
// multi-select — this is a single-focus cursor). Enter on a focused
// card opens the detail view. Escape clears the focus. The focus
// indicator is a `.card-focused` outline (accent2, distinct from the
// multi-select accent outline).
const isArrow = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key);
if ((isArrow || e.key === 'Enter') && document.activeElement === document.body) {
const blockers = [modal, detail, search, tasks, document.getElementById('gallery'), document.getElementById('settings-overlay'), document.getElementById('help'), document.getElementById('email-overlay'), document.getElementById('calendar'), document.getElementById('shortcuts')];
if (blockers.some(el => el?.classList.contains('open'))) return;
const board = document.getElementById('board');
if (!board) return;
const cards = [...board.querySelectorAll('.board-card[data-id]')];
if (!cards.length) return;
if (e.key === 'Enter') {
const focused = board.querySelector('.card-focused');
if (focused) {
e.preventDefault();
window._openDetail(focused.dataset.id);
clearCardFocus();
}
return;
}
e.preventDefault();
const focused = board.querySelector('.card-focused');
let curIdx = focused ? cards.indexOf(focused) : -1;
if (curIdx < 0) {
// No focus yet → select the first card
setCardFocus(cards[0]);
return;
}
// Compute columns per row from the grid layout
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]);
}
});
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;
if (!body.includes('#inbox')) body = (body ? body + '\n\n' : '') + '#inbox';
// 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;
await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1, bucket_id: bucketId } });
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 ──
checkAuth();
// ── Test runner: now provided by the "tests" plugin (see public/plugins/tests/) ──