diff --git a/artifacts/2026-02-22/centralized_keyboard_shortcuts.md b/artifacts/2026-02-22/centralized_keyboard_shortcuts.md
new file mode 100644
index 000000000..290b90c19
--- /dev/null
+++ b/artifacts/2026-02-22/centralized_keyboard_shortcuts.md
@@ -0,0 +1,47 @@
+# Centralizing Keyboard Shortcuts
+
+**Date:** 2026-02-22
+
+## Objective
+Centralize the definitions of all keyboard shortcuts into a single configuration file (`client/plugins/constants.js`). Currently, hotkeys are scattered across various components (e.g., `Appbar.vue`, `ContextMenuDropdown.vue`, `ShortcutsModal.vue`), with hardcoded keys like `Ctrl+K`, `Alt+H`, etc.
+
+## Proposed Strategy
+1. **Extend `$hotkeys` in `client/plugins/constants.js`:**
+ Add a new section `App` or `Global` and `Batch` to store the exact combination of keys for each action.
+ Example:
+ ```javascript
+ const hotkeys = {
+ // ... existings ones ...
+ Global: {
+ Home: 'Alt-H',
+ Library: 'Alt-L',
+ Series: 'Alt-S',
+ Collections: 'Alt-C',
+ Authors: 'Alt-A',
+ ShortcutsHelper: 'Shift-/'
+ },
+ Batch: {
+ SelectAll: 'Ctrl-A',
+ Consolidate: 'Ctrl-K',
+ Merge: 'Ctrl-M',
+ MoveToLibrary: 'Alt-M',
+ ResetMetadata: 'Alt-R',
+ QuickMatch: 'Alt-Q',
+ Cancel: 'Escape'
+ },
+ ItemView: {
+ // ...
+ }
+ }
+ ```
+
+2. **Refactor Event Listeners:**
+ Modify `handleKeyDown` in `Appbar.vue` and `keyDown` in `default.vue` to check against these constants instead of relying on hardcoded `e.key` checks. They can use the existing `this.getHotkeyName(e)` (which returns format `Ctrl-K` or `Alt-K`) from `default.vue`.
+
+3. **Refactor Visual Components:**
+ Components like `ContextMenuDropdown.vue` and `ShortcutsModal.vue` should import or use `this.$hotkeys` mapping to display the combination in the UI, rendering the exact combination so if it changes in `constants.js`, it updates automatically everywhere.
+
+## Expected Outcome
+- Easier to manage and modify shortcuts in the future.
+- Reduced risk of conflicts.
+- A single source of truth for the codebase and documentation.
diff --git a/artifacts/index.md b/artifacts/index.md
index 68c48c0e4..80b5d462b 100644
--- a/artifacts/index.md
+++ b/artifacts/index.md
@@ -23,6 +23,7 @@ This index provides a quick reference for specification and documentation files
| **2026-02-17** | [ui_enhancements.md](2026-02-17/ui_enhancements.md) | Specification for "View All" shortcuts on Home view shelves with specific sorting. |
| **2026-02-20** | [promote_file_to_book.md](2026-02-20/promote_file_to_book.md) | Specification for "promoting" files from an existing book into a standalone library item, including a "Split Book" wizard. |
| **2026-02-20** | [move_to_library_keyboard_shortcuts.md](2026-02-20/move_to_library_keyboard_shortcuts.md) | Specification for keyboard-shortcut-enabled library buttons in the "Move to Library" dialog. |
+| **2026-02-22** | [centralized_keyboard_shortcuts.md](2026-02-22/centralized_keyboard_shortcuts.md) | Specification for centralizing keyboard shortcut definitions into a single configuration file. |
| **General** | [docs/consolidate_feature.md](docs/consolidate_feature.md) | Comprehensive documentation for the "Consolidate" feature, including conflict resolution and technical details. |
| **General** | [docs/item_restructuring_guide.md](docs/item_restructuring_guide.md) | Guide for Moving, Merging, and Splitting (Promoting) library items. |
| **General** | [docs/metadata_management_tools.md](docs/metadata_management_tools.md) | Documentation for Reset Metadata and Batch Reset operations. |
diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue
index 52b08cba4..205a6ae83 100644
--- a/client/components/app/Appbar.vue
+++ b/client/components/app/Appbar.vue
@@ -48,6 +48,12 @@
+
+
+ keyboard
+
+
+
{{ username }}
@@ -177,7 +183,8 @@ export default {
const options = [
{
text: this.$strings.ButtonQuickMatch,
- action: 'quick-match'
+ action: 'quick-match',
+ shortcut: this.$hotkeys.Batch.MATCH
}
]
@@ -195,7 +202,8 @@ export default {
options.push({
text: 'Reset Metadata',
- action: 'reset-metadata'
+ action: 'reset-metadata',
+ shortcut: this.$hotkeys.Batch.RESET
})
// The limit of 50 is introduced because of the URL length. Each id has 36 chars, so 36 * 40 = 1440
@@ -211,21 +219,24 @@ export default {
if (this.userCanDelete) {
options.push({
text: this.$strings.LabelMoveToLibrary,
- action: 'move-to-library'
+ action: 'move-to-library',
+ shortcut: this.$hotkeys.Batch.MOVE
})
// Merge option - only for books and if multiple selected
if (this.isBookLibrary && this.selectedMediaItems.length > 1) {
options.push({
text: this.$strings.LabelMerge,
- action: 'merge'
+ action: 'merge',
+ shortcut: this.$hotkeys.Batch.MERGE
})
}
if (this.isBookLibrary) {
options.push({
text: 'Consolidate',
- action: 'consolidate'
+ action: 'consolidate',
+ shortcut: this.$hotkeys.Batch.CONSOLIDATE
})
}
}
@@ -534,68 +545,74 @@ export default {
batchAutoMatchClick() {
this.$store.commit('globals/setShowBatchQuickMatchModal', true)
},
+ getHotkeyName(e) {
+ if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return null
+
+ var keyCode = e.keyCode || e.which
+ if (!this.$keynames[keyCode]) return null
+
+ var name = this.$keynames[keyCode]
+ if (e.ctrlKey || e.metaKey) name = 'Ctrl-' + name
+ if (e.altKey) name = 'Alt-' + name
+ if (e.shiftKey) name = 'Shift-' + name
+ return name
+ },
handleKeyDown(e) {
- if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) {
- return
- }
+ const name = this.getHotkeyName(e)
+ if (!name) return
- const ctrlOrMeta = e.ctrlKey || e.metaKey
- const shift = e.shiftKey
- const alt = e.altKey
-
- if (ctrlOrMeta && e.key.toLowerCase() === 'a') {
+ if (name === this.$hotkeys.Batch.SELECT_ALL) {
if (this.isBookshelfPage) {
e.preventDefault()
this.$eventBus.$emit('bookshelf_select_all')
}
- } else if (ctrlOrMeta && e.key.toLowerCase() === 'k') {
+ } else if (name === this.$hotkeys.Batch.CONSOLIDATE) {
e.preventDefault()
if (this.numMediaItemsSelected > 0) {
this.batchConsolidate()
} else if (this.isItemPage) {
this.$eventBus.$emit('item_shortcut_consolidate')
}
- } else if (ctrlOrMeta && !shift && e.key.toLowerCase() === 'm') {
- if (this.numMediaItemsSelected > 1) {
- e.preventDefault()
- this.batchMerge()
- }
- } else if ((ctrlOrMeta && shift && e.key.toLowerCase() === 'm') || (alt && e.key.toLowerCase() === 'm')) {
+ } else if (name === this.$hotkeys.Batch.MERGE || name === this.$hotkeys.Batch.MOVE) {
e.preventDefault()
- if (this.numMediaItemsSelected > 0) {
- this.batchMoveToLibrary()
- } else if (this.isItemPage) {
- this.$eventBus.$emit('item_shortcut_move')
+ if (name === this.$hotkeys.Batch.MERGE && this.numMediaItemsSelected > 1) {
+ this.batchMerge()
+ } else if (name === this.$hotkeys.Batch.MOVE) {
+ if (this.numMediaItemsSelected > 0) {
+ this.batchMoveToLibrary()
+ } else if (this.isItemPage) {
+ this.$eventBus.$emit('item_shortcut_move')
+ }
}
- } else if (alt && e.key.toLowerCase() === 'r') {
+ } else if (name === this.$hotkeys.Batch.RESET) {
e.preventDefault()
if (this.numMediaItemsSelected > 0) {
this.batchResetMetadata()
} else if (this.isItemPage) {
this.$eventBus.$emit('item_shortcut_reset')
}
- } else if (alt && e.key.toLowerCase() === 'q') {
+ } else if (name === this.$hotkeys.Batch.MATCH) {
e.preventDefault()
if (this.numMediaItemsSelected > 0) {
this.batchAutoMatchClick()
} else if (this.isItemPage) {
this.$eventBus.$emit('item_shortcut_match')
}
- } else if (alt && this.currentLibrary?.id) {
+ } else if (this.currentLibrary?.id) {
const libId = this.currentLibrary.id
- if (e.key.toLowerCase() === 'h') {
+ if (name === this.$hotkeys.Navigation.HOME) {
e.preventDefault()
this.$router.push(`/library/${libId}`)
- } else if (e.key.toLowerCase() === 'l') {
+ } else if (name === this.$hotkeys.Navigation.LIBRARY) {
e.preventDefault()
this.$router.push(`/library/${libId}/bookshelf`)
- } else if (e.key.toLowerCase() === 's') {
+ } else if (name === this.$hotkeys.Navigation.SERIES) {
e.preventDefault()
this.$router.push(`/library/${libId}/bookshelf/series`)
- } else if (e.key.toLowerCase() === 'c') {
+ } else if (name === this.$hotkeys.Navigation.COLLECTIONS) {
e.preventDefault()
this.$router.push(`/library/${libId}/bookshelf/collections`)
- } else if (e.key.toLowerCase() === 'a') {
+ } else if (name === this.$hotkeys.Navigation.AUTHORS) {
e.preventDefault()
this.$router.push(`/library/${libId}/bookshelf/authors`)
}
diff --git a/client/components/modals/ShortcutsModal.vue b/client/components/modals/ShortcutsModal.vue
new file mode 100644
index 000000000..60bf7ecf7
--- /dev/null
+++ b/client/components/modals/ShortcutsModal.vue
@@ -0,0 +1,79 @@
+
+
+