feat(ui): improve keyboard shortcuts discoverability and centralized management

This commit is contained in:
Tiberiu Ichim 2026-02-22 08:18:50 +02:00
parent b581b4f86c
commit d7a2f4a515
9 changed files with 233 additions and 39 deletions

View file

@ -48,6 +48,12 @@
</ui-tooltip>
</nuxt-link>
<div class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1" @click="$store.commit('globals/setShowShortcutsModal', true)">
<ui-tooltip text="Keyboard Shortcuts (?)" direction="bottom" class="flex items-center">
<span class="material-symbols text-2xl" aria-label="Keyboard Shortcuts" role="button">keyboard</span>
</ui-tooltip>
</div>
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded-sm shadow-xs ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg/40" aria-haspopup="listbox" aria-expanded="true">
<span class="items-center hidden md:flex">
<span class="block truncate">{{ username }}</span>
@ -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`)
}

View file

@ -0,0 +1,79 @@
<template>
<ui-modal v-model="show" :width="600" title="Keyboard Shortcuts" @close="close">
<div class="p-6 text-sm text-gray-200">
<div v-for="(section, index) in shortcutSections" :key="index" class="mb-6 last:mb-0">
<h3 class="text-lg font-semibold text-white mb-3">{{ section.title }}</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-2 gap-x-4">
<div v-for="shortcut in section.shortcuts" :key="shortcut.keys" class="flex justify-between items-center py-1 border-b border-white/10 last:border-b-0">
<span>{{ shortcut.action }}</span>
<div class="flex gap-1">
<span v-for="(key, i) in shortcut.keys.replace(/Key/g, '').split('-')" :key="i" class="px-2 py-0.5 bg-bg font-mono text-xs rounded-sm border border-gray-600 text-gray-300">
{{ key }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end p-4 border-t border-white/10">
<ui-btn @click="close">Close</ui-btn>
</div>
</ui-modal>
</template>
<script>
export default {
computed: {
shortcutSections() {
if (!this.$hotkeys) return []
return [
{
title: 'Global Navigation',
shortcuts: [
{ action: 'Home', keys: this.$hotkeys.Navigation.HOME },
{ action: 'Library', keys: this.$hotkeys.Navigation.LIBRARY },
{ action: 'Series', keys: this.$hotkeys.Navigation.SERIES },
{ action: 'Collections', keys: this.$hotkeys.Navigation.COLLECTIONS },
{ action: 'Authors', keys: this.$hotkeys.Navigation.AUTHORS },
{ action: 'Show Shortcuts Helper', keys: this.$hotkeys.Global.SHORTCUTS_HELPER }
]
},
{
title: 'Library Actions (Batch Selection)',
shortcuts: [
{ action: 'Select All Items', keys: this.$hotkeys.Batch.SELECT_ALL },
{ action: 'Consolidate', keys: this.$hotkeys.Batch.CONSOLIDATE },
{ action: 'Merge Items', keys: this.$hotkeys.Batch.MERGE },
{ action: 'Move to Library', keys: this.$hotkeys.Batch.MOVE },
{ action: 'Reset Metadata', keys: this.$hotkeys.Batch.RESET },
{ action: 'Quick Match', keys: this.$hotkeys.Batch.MATCH },
{ action: 'Clear Selection', keys: this.$hotkeys.Batch.CANCEL }
]
},
{
title: 'Item View Actions',
shortcuts: [
{ action: 'Consolidate', keys: this.$hotkeys.Item.CONSOLIDATE },
{ action: 'Move to Library', keys: this.$hotkeys.Item.MOVE },
{ action: 'Reset Metadata', keys: this.$hotkeys.Item.RESET },
{ action: 'Match', keys: this.$hotkeys.Item.MATCH }
]
}
]
},
show: {
get() {
return this.$store.state.globals.showShortcutsModal
},
set(val) {
this.$store.commit('globals/setShowShortcutsModal', val)
}
}
},
methods: {
close() {
this.show = false
}
}
}
</script>

View file

@ -14,7 +14,8 @@
<template v-for="(item, index) in items">
<template v-if="item.subitems">
<button :key="index" role="menuitem" aria-haspopup="menu" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default w-full" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
<p>{{ item.text }}</p>
<p class="text-left grow">{{ item.text }}</p>
<p v-if="item.shortcut" class="text-right text-gray-400 pl-4 whitespace-nowrap">{{ item.shortcut.replace(/Key/g, '').replace(/-/g, '+') }}</p>
</button>
<div
v-if="mouseoverItemIndex === index"
@ -26,12 +27,14 @@
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
>
<button v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(subitem.action, subitem.data)">
<p>{{ subitem.text }}</p>
<p class="text-left grow">{{ subitem.text }}</p>
<p v-if="subitem.shortcut" class="text-right text-gray-400 pl-4 whitespace-nowrap">{{ subitem.shortcut.replace(/Key/g, '').replace(/-/g, '+') }}</p>
</button>
</div>
</template>
<button v-else :key="index" role="menuitem" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer w-full" @click.stop="clickAction(item.action)">
<p class="text-left">{{ item.text }}</p>
<p class="text-left grow">{{ item.text }}</p>
<p v-if="item.shortcut" class="text-right text-gray-400 pl-4 whitespace-nowrap">{{ item.shortcut.replace(/Key/g, '').replace(/-/g, '+') }}</p>
</button>
</template>
</div>