Merge pull request #138 from Pandipipas/add-keyboard-shortcut-settings-window

Add configurable keyboard shortcuts for scoreboard updates
This commit is contained in:
Pandipipas
2026-02-25 15:18:51 +01:00
committed by GitHub
4 changed files with 395 additions and 6 deletions
+36
View File
@@ -12,6 +12,18 @@ type Translations = {
settingsDescription: string;
settingsLanguageLabel: string;
settingsLanguageHint: string;
settingsShortcutTitle: string;
settingsShortcutDescription: string;
settingsShortcutLeftIncrementLabel: string;
settingsShortcutLeftIncrementHint: string;
settingsShortcutLeftDecrementLabel: string;
settingsShortcutLeftDecrementHint: string;
settingsShortcutRightIncrementLabel: string;
settingsShortcutRightIncrementHint: string;
settingsShortcutRightDecrementLabel: string;
settingsShortcutRightDecrementHint: string;
settingsShortcutReset: string;
settingsShortcutRecordingHint: string;
languageEnglish: string;
languageSpanish: string;
scoreboardUnassigned: string;
@@ -87,6 +99,18 @@ const messages: Record<Locale, Translations> = {
settingsDescription: 'Dashboard and bundle configuration.',
settingsLanguageLabel: 'Language',
settingsLanguageHint: 'Choose the dashboard language.',
settingsShortcutTitle: 'Keyboard shortcuts',
settingsShortcutDescription: 'Configure quick keys to update the score for each side.',
settingsShortcutLeftIncrementLabel: 'P1 score +1',
settingsShortcutLeftIncrementHint: 'Increases left player score by one.',
settingsShortcutLeftDecrementLabel: 'P1 score -1',
settingsShortcutLeftDecrementHint: 'Decreases left player score by one.',
settingsShortcutRightIncrementLabel: 'P2 score +1',
settingsShortcutRightIncrementHint: 'Increases right player score by one.',
settingsShortcutRightDecrementLabel: 'P2 score -1',
settingsShortcutRightDecrementHint: 'Decreases right player score by one.',
settingsShortcutReset: 'Reset shortcuts',
settingsShortcutRecordingHint: 'Press the desired shortcut now (example: Alt+1).',
languageEnglish: 'English',
languageSpanish: 'Spanish',
scoreboardUnassigned: '(Unassigned)',
@@ -158,6 +182,18 @@ const messages: Record<Locale, Translations> = {
settingsDescription: 'Configuración del dashboard y del bundle.',
settingsLanguageLabel: 'Idioma',
settingsLanguageHint: 'Selecciona el idioma del dashboard.',
settingsShortcutTitle: 'Atajos de teclado',
settingsShortcutDescription: 'Configura teclas rápidas para actualizar el score de cada lado.',
settingsShortcutLeftIncrementLabel: 'Score P1 +1',
settingsShortcutLeftIncrementHint: 'Incrementa en uno el score del jugador izquierdo.',
settingsShortcutLeftDecrementLabel: 'Score P1 -1',
settingsShortcutLeftDecrementHint: 'Reduce en uno el score del jugador izquierdo.',
settingsShortcutRightIncrementLabel: 'Score P2 +1',
settingsShortcutRightIncrementHint: 'Incrementa en uno el score del jugador derecho.',
settingsShortcutRightDecrementLabel: 'Score P2 -1',
settingsShortcutRightDecrementHint: 'Reduce en uno el score del jugador derecho.',
settingsShortcutReset: 'Restablecer atajos',
settingsShortcutRecordingHint: 'Pulsa ahora el atajo deseado (ejemplo: Alt+1).',
languageEnglish: 'Inglés',
languageSpanish: 'Castellano',
scoreboardUnassigned: '(Sin asignar)',
+53 -1
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onMounted, onUnmounted } from 'vue';
import { t } from './i18n';
import { useScoreboardStore } from './stores/scoreboard';
import { isShortcutMatch, useShortcutSettingsStore } from './stores/shortcut-settings';
const menuItems = computed(() => [
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' },
@@ -11,6 +13,56 @@ const menuItems = computed(() => [
]);
const logoUrl = new URL('./image.png', import.meta.url).href;
const scoreboardStore = useScoreboardStore();
const shortcutSettingsStore = useShortcutSettingsStore();
const isEditableTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) {
return false;
}
return target.isContentEditable
|| ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
|| Boolean(target.closest('[contenteditable="true"]'));
};
const onShortcutPress = (event: KeyboardEvent) => {
if (isEditableTarget(event.target) || document.body.dataset.shortcutRecording === 'true') {
return;
}
const { shortcuts } = shortcutSettingsStore;
if (isShortcutMatch(event, shortcuts.leftIncrement)) {
scoreboardStore.leftScore += 1;
event.preventDefault();
return;
}
if (isShortcutMatch(event, shortcuts.leftDecrement)) {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore - 1);
event.preventDefault();
return;
}
if (isShortcutMatch(event, shortcuts.rightIncrement)) {
scoreboardStore.rightScore += 1;
event.preventDefault();
return;
}
if (isShortcutMatch(event, shortcuts.rightDecrement)) {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore - 1);
event.preventDefault();
}
};
onMounted(() => {
window.addEventListener('keydown', onShortcutPress);
});
onUnmounted(() => {
window.removeEventListener('keydown', onShortcutPress);
});
</script>
<template>
@@ -0,0 +1,183 @@
import { defineStore } from 'pinia';
import { reactive } from 'vue';
export type ShortcutAction = 'leftIncrement' | 'leftDecrement' | 'rightIncrement' | 'rightDecrement';
export type ShortcutSettings = Record<ShortcutAction, string>;
type ShortcutParts = {
key: string;
altKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
metaKey: boolean;
};
const STORAGE_KEY = 'scoreko-dev.shortcut-settings';
const defaultShortcuts: ShortcutSettings = {
leftIncrement: 'Q',
leftDecrement: 'A',
rightIncrement: 'P',
rightDecrement: 'L',
};
const normalizeKey = (value: unknown): string => {
if (typeof value !== 'string') {
return '';
}
const normalized = value.trim();
return normalized.length === 1 ? normalized.toUpperCase() : '';
};
const normalizeShortcut = (value: unknown): string => {
if (typeof value !== 'string') {
return '';
}
const parts = value.split('+').map((part) => part.trim()).filter(Boolean);
if (parts.length === 0) {
return '';
}
const modifiers = {
altKey: false,
ctrlKey: false,
shiftKey: false,
metaKey: false,
};
let key = '';
for (const part of parts) {
const lowered = part.toLowerCase();
if (lowered === 'alt') {
modifiers.altKey = true;
continue;
}
if (lowered === 'ctrl' || lowered === 'control') {
modifiers.ctrlKey = true;
continue;
}
if (lowered === 'shift') {
modifiers.shiftKey = true;
continue;
}
if (lowered === 'meta' || lowered === 'cmd' || lowered === 'command') {
modifiers.metaKey = true;
continue;
}
key = normalizeKey(part);
}
if (!key) {
return '';
}
return formatShortcut({ key, ...modifiers });
};
const normalizeSettings = (input: unknown): ShortcutSettings => {
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
return {
leftIncrement: normalizeShortcut(candidate.leftIncrement) || defaultShortcuts.leftIncrement,
leftDecrement: normalizeShortcut(candidate.leftDecrement) || defaultShortcuts.leftDecrement,
rightIncrement: normalizeShortcut(candidate.rightIncrement) || defaultShortcuts.rightIncrement,
rightDecrement: normalizeShortcut(candidate.rightDecrement) || defaultShortcuts.rightDecrement,
};
};
const readStoredSettings = (): ShortcutSettings => {
if (typeof window === 'undefined') {
return { ...defaultShortcuts };
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return { ...defaultShortcuts };
}
return normalizeSettings(JSON.parse(raw));
} catch {
return { ...defaultShortcuts };
}
};
const persistSettings = (settings: ShortcutSettings) => {
if (typeof window === 'undefined') {
return;
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
};
export const formatShortcut = ({ key, altKey, ctrlKey, shiftKey, metaKey }: ShortcutParts): string => {
const parts: string[] = [];
if (ctrlKey) {
parts.push('Ctrl');
}
if (altKey) {
parts.push('Alt');
}
if (shiftKey) {
parts.push('Shift');
}
if (metaKey) {
parts.push('Meta');
}
parts.push(key);
return parts.join('+');
};
export const eventToShortcut = (event: KeyboardEvent): string => {
const key = normalizeKey(event.key);
if (!key) {
return '';
}
return formatShortcut({
key,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
metaKey: event.metaKey,
});
};
export const isShortcutMatch = (event: KeyboardEvent, shortcut: string): boolean => {
const normalizedShortcut = normalizeShortcut(shortcut);
if (!normalizedShortcut) {
return false;
}
return eventToShortcut(event) === normalizedShortcut;
};
export const useShortcutSettingsStore = defineStore('shortcut-settings', () => {
const shortcuts = reactive<ShortcutSettings>(readStoredSettings());
const setShortcut = (action: ShortcutAction, value: string) => {
const normalized = normalizeShortcut(value);
if (!normalized) {
return;
}
shortcuts[action] = normalized;
persistSettings(shortcuts);
};
const resetShortcuts = () => {
shortcuts.leftIncrement = defaultShortcuts.leftIncrement;
shortcuts.leftDecrement = defaultShortcuts.leftDecrement;
shortcuts.rightIncrement = defaultShortcuts.rightIncrement;
shortcuts.rightDecrement = defaultShortcuts.rightDecrement;
persistSettings(shortcuts);
};
return {
shortcuts,
setShortcut,
resetShortcuts,
};
});
+123 -5
View File
@@ -1,8 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import { useHead } from '@unhead/vue';
import type { Locale } from '../i18n';
import { locale, setLocale, t } from '../i18n';
import {
eventToShortcut,
type ShortcutAction,
useShortcutSettingsStore,
} from '../stores/shortcut-settings';
defineOptions({ name: 'SettingsView' });
@@ -18,6 +23,61 @@ const selectedLanguage = computed<Locale>({
},
});
const shortcutSettingsStore = useShortcutSettingsStore();
const recordingAction = ref<ShortcutAction | null>(null);
const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: string }[]>(() => [
{ action: 'leftIncrement', label: t('settingsShortcutLeftIncrementLabel'), hint: t('settingsShortcutLeftIncrementHint') },
{ action: 'leftDecrement', label: t('settingsShortcutLeftDecrementLabel'), hint: t('settingsShortcutLeftDecrementHint') },
{ action: 'rightIncrement', label: t('settingsShortcutRightIncrementLabel'), hint: t('settingsShortcutRightIncrementHint') },
{ action: 'rightDecrement', label: t('settingsShortcutRightDecrementLabel'), hint: t('settingsShortcutRightDecrementHint') },
]);
const stopRecording = () => {
recordingAction.value = null;
if (typeof document !== 'undefined') {
delete document.body.dataset.shortcutRecording;
}
};
const onRecordKeydown = (event: KeyboardEvent) => {
if (!recordingAction.value) {
return;
}
const shortcut = eventToShortcut(event);
if (!shortcut) {
return;
}
event.preventDefault();
shortcutSettingsStore.setShortcut(recordingAction.value, shortcut);
stopRecording();
};
const startRecording = (action: ShortcutAction) => {
if (recordingAction.value === action) {
stopRecording();
return;
}
recordingAction.value = action;
if (typeof document !== 'undefined') {
document.body.dataset.shortcutRecording = 'true';
}
};
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onRecordKeydown);
}
onBeforeUnmount(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onRecordKeydown);
}
stopRecording();
});
useHead(() => ({ title: t('settingsTitle') }));
</script>
@@ -33,22 +93,80 @@ useHead(() => ({ title: t('settingsTitle') }));
<QCard
flat
bordered
class="q-pa-md"
style="max-width: 360px;"
class="q-pa-md settings-card"
>
<QCardSection class="q-pa-none">
<QCardSection class="q-pa-none q-mb-lg">
<div class="text-subtitle1 q-mb-sm">
{{ t('settingsLanguageLabel') }}
</div>
<QSelect
v-model="selectedLanguage"
outlined
emit-value
map-options
:label="t('settingsLanguageLabel')"
:options="languageOptions"
/>
<div class="text-caption text-grey-5 q-mt-sm">
{{ t('settingsLanguageHint') }}
</div>
</QCardSection>
<QSeparator class="q-mb-lg" />
<QCardSection class="q-pa-none">
<div class="row items-center justify-between q-mb-sm">
<div class="text-subtitle1">
{{ t('settingsShortcutTitle') }}
</div>
<QBtn
round
dense
flat
color="primary"
icon="restart_alt"
:aria-label="t('settingsShortcutReset')"
@click="shortcutSettingsStore.resetShortcuts"
>
<QTooltip>{{ t('settingsShortcutReset') }}</QTooltip>
</QBtn>
</div>
<div class="text-caption text-grey-5 q-mb-md">
{{ t('settingsShortcutDescription') }}
</div>
<div class="column q-gutter-md">
<QInput
v-for="field in shortcutFields"
:key="field.action"
:model-value="shortcutSettingsStore.shortcuts[field.action]"
readonly
:label="field.label"
>
<template #append>
<QBtn
flat
round
dense
:icon="recordingAction === field.action ? 'stop_circle' : 'keyboard'"
:color="recordingAction === field.action ? 'negative' : 'primary'"
@click="startRecording(field.action)"
/>
</template>
<template #hint>
{{ recordingAction === field.action ? t('settingsShortcutRecordingHint') : field.hint }}
</template>
</QInput>
</div>
</QCardSection>
</QCard>
</QPage>
</template>
<style scoped>
.settings-card {
max-width: 720px;
}
</style>