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) => ` +
+ ${esc(s.key)} + ${esc(s.description())} +
+ `).join(''); + + panel.insertAdjacentHTML('beforeend', ` + + + `); + + panel.querySelector('.modal-panel__close').addEventListener('click', () => overlay.remove()); + document.addEventListener('keydown', function onEsc(e) { + if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', onEsc); } + }); + + overlay.appendChild(panel); + document.body.appendChild(overlay); + if (window.lucide) window.lucide.createIcons({ el: panel }); } function initOfflineBanner() { diff --git a/public/styles/layout.css b/public/styles/layout.css index 9a5c4ea..dfb712e 100755 --- a/public/styles/layout.css +++ b/public/styles/layout.css @@ -1897,6 +1897,49 @@ } } +/* -------------------------------------------------------- + * Keyboard Shortcut Modal + * -------------------------------------------------------- */ +.shortcuts-list { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.shortcuts-row { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--color-border-subtle); +} + +.shortcuts-row:last-child { + border-bottom: none; +} + +.shortcut-kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + padding: var(--space-0h) var(--space-2); + background: var(--color-surface-3); + border: 1px solid var(--color-border); + border-bottom: 2px solid var(--color-border); + border-radius: var(--radius-xs); + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--color-text-primary); + white-space: nowrap; + flex-shrink: 0; +} + +.shortcut-desc { + font-size: var(--text-sm); + color: var(--color-text-secondary); +} + /* -------------------------------------------------------- * Offline-Banner * -------------------------------------------------------- */