diff --git a/public/locales/de.json b/public/locales/de.json index 9f07956..8a345a1 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -891,5 +891,15 @@ }, "offline": { "banner": "Offline – Verbindung wird wiederhergestellt…" + }, + "shortcuts": { + "search": "Suche öffnen", + "new": "Neuen Eintrag erstellen", + "help": "Tastenkombinationen", + "goDash": "Dashboard", + "goTasks": "Aufgaben", + "goCal": "Kalender", + "goShop": "Einkaufsliste", + "goNotes": "Notizen" } } diff --git a/public/locales/en.json b/public/locales/en.json index 58e04be..1dbf656 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -872,5 +872,15 @@ }, "offline": { "banner": "Offline – reconnecting…" + }, + "shortcuts": { + "search": "Open search", + "new": "Create new entry", + "help": "Keyboard shortcuts", + "goDash": "Dashboard", + "goTasks": "Tasks", + "goCal": "Calendar", + "goShop": "Shopping list", + "goNotes": "Notes" } } \ No newline at end of file diff --git a/public/router.js b/public/router.js index 6934195..5043784 100644 --- a/public/router.js +++ b/public/router.js @@ -6,6 +6,7 @@ import { api, auth } from '/api.js'; import { initI18n, getLocale, t } from '/i18n.js'; +import { esc } from '/utils/html.js'; import { init as initReminders, stop as stopReminders } from '/reminders.js'; // -------------------------------------------------------- @@ -542,6 +543,90 @@ function renderAppShell(container) { initNavHideOnScroll(container); initSearch(container); initOfflineBanner(); + initKeyboardShortcuts(); +} + +const SHORTCUTS = [ + { key: '/', description: () => t('shortcuts.search'), action: () => document.getElementById('search-btn')?.click() }, + { key: 'n', description: () => t('shortcuts.new'), action: () => document.querySelector('.page-fab')?.click() }, + { key: '?', description: () => t('shortcuts.help'), action: () => showShortcutsModal() }, + { key: 'g d', description: () => t('shortcuts.goDash'), action: () => navigate('/') }, + { key: 'g t', description: () => t('shortcuts.goTasks'), action: () => navigate('/tasks') }, + { key: 'g c', description: () => t('shortcuts.goCal'), action: () => navigate('/calendar') }, + { key: 'g s', description: () => t('shortcuts.goShop'), action: () => navigate('/shopping') }, + { key: 'g n', description: () => t('shortcuts.goNotes'), action: () => navigate('/notes') }, +]; + +let _pendingKey = null; +let _pendingTimer = null; + +function initKeyboardShortcuts() { + document.addEventListener('keydown', (e) => { + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (document.activeElement?.isContentEditable) return; + if (document.querySelector('.modal-overlay') && e.key !== 'Escape') return; + + const key = e.key.toLowerCase(); + + if (_pendingKey === 'g' && key !== 'g') { + clearTimeout(_pendingTimer); + _pendingKey = null; + const combo = `g ${key}`; + const shortcut = SHORTCUTS.find((s) => s.key === combo); + if (shortcut) { e.preventDefault(); shortcut.action(); } + return; + } + + if (key === 'g') { + _pendingKey = 'g'; + _pendingTimer = setTimeout(() => { _pendingKey = null; }, 1000); + return; + } + + const shortcut = SHORTCUTS.find((s) => s.key === key && !s.key.includes(' ')); + if (shortcut) { e.preventDefault(); shortcut.action(); } + }); +} + +function showShortcutsModal() { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.setAttribute('aria-modal', 'true'); + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); + + const panel = document.createElement('div'); + panel.className = 'modal-panel modal-panel--sm'; + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-label', t('shortcuts.help')); + + const rows = SHORTCUTS.map((s) => ` +