mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
Add configurable keyboard shortcuts for score updates
This commit is contained in:
@@ -12,6 +12,17 @@ type Translations = {
|
|||||||
settingsDescription: string;
|
settingsDescription: string;
|
||||||
settingsLanguageLabel: string;
|
settingsLanguageLabel: string;
|
||||||
settingsLanguageHint: 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;
|
||||||
languageEnglish: string;
|
languageEnglish: string;
|
||||||
languageSpanish: string;
|
languageSpanish: string;
|
||||||
scoreboardUnassigned: string;
|
scoreboardUnassigned: string;
|
||||||
@@ -87,6 +98,17 @@ const messages: Record<Locale, Translations> = {
|
|||||||
settingsDescription: 'Dashboard and bundle configuration.',
|
settingsDescription: 'Dashboard and bundle configuration.',
|
||||||
settingsLanguageLabel: 'Language',
|
settingsLanguageLabel: 'Language',
|
||||||
settingsLanguageHint: 'Choose the dashboard 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',
|
||||||
languageEnglish: 'English',
|
languageEnglish: 'English',
|
||||||
languageSpanish: 'Spanish',
|
languageSpanish: 'Spanish',
|
||||||
scoreboardUnassigned: '(Unassigned)',
|
scoreboardUnassigned: '(Unassigned)',
|
||||||
@@ -158,6 +180,17 @@ const messages: Record<Locale, Translations> = {
|
|||||||
settingsDescription: 'Configuración del dashboard y del bundle.',
|
settingsDescription: 'Configuración del dashboard y del bundle.',
|
||||||
settingsLanguageLabel: 'Idioma',
|
settingsLanguageLabel: 'Idioma',
|
||||||
settingsLanguageHint: 'Selecciona el idioma del dashboard.',
|
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',
|
||||||
languageEnglish: 'Inglés',
|
languageEnglish: 'Inglés',
|
||||||
languageSpanish: 'Castellano',
|
languageSpanish: 'Castellano',
|
||||||
scoreboardUnassigned: '(Sin asignar)',
|
scoreboardUnassigned: '(Sin asignar)',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, onMounted, onUnmounted } from 'vue';
|
||||||
import { t } from './i18n';
|
import { t } from './i18n';
|
||||||
|
import { useScoreboardStore } from './stores/scoreboard';
|
||||||
|
import { useShortcutSettingsStore } from './stores/shortcut-settings';
|
||||||
|
|
||||||
const menuItems = computed(() => [
|
const menuItems = computed(() => [
|
||||||
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' },
|
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' },
|
||||||
@@ -11,6 +13,57 @@ const menuItems = computed(() => [
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const logoUrl = new URL('./image.png', import.meta.url).href;
|
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 (event.ctrlKey || event.altKey || event.metaKey || isEditableTarget(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
const { shortcuts } = shortcutSettingsStore;
|
||||||
|
if (key === shortcuts.leftIncrement) {
|
||||||
|
scoreboardStore.leftScore += 1;
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === shortcuts.leftDecrement) {
|
||||||
|
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore - 1);
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === shortcuts.rightIncrement) {
|
||||||
|
scoreboardStore.rightScore += 1;
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === shortcuts.rightDecrement) {
|
||||||
|
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore - 1);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', onShortcutPress);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', onShortcutPress);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { reactive } from 'vue';
|
||||||
|
|
||||||
|
export type ShortcutAction = 'leftIncrement' | 'leftDecrement' | 'rightIncrement' | 'rightDecrement';
|
||||||
|
|
||||||
|
export type ShortcutSettings = Record<ShortcutAction, string>;
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'scoreko-dev.shortcut-settings';
|
||||||
|
|
||||||
|
const defaultShortcuts: ShortcutSettings = {
|
||||||
|
leftIncrement: 'q',
|
||||||
|
leftDecrement: 'a',
|
||||||
|
rightIncrement: 'p',
|
||||||
|
rightDecrement: 'l',
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeShortcutKey = (value: unknown): string => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
return normalized.slice(0, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSettings = (input: unknown): ShortcutSettings => {
|
||||||
|
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
leftIncrement: normalizeShortcutKey(candidate.leftIncrement) || defaultShortcuts.leftIncrement,
|
||||||
|
leftDecrement: normalizeShortcutKey(candidate.leftDecrement) || defaultShortcuts.leftDecrement,
|
||||||
|
rightIncrement: normalizeShortcutKey(candidate.rightIncrement) || defaultShortcuts.rightIncrement,
|
||||||
|
rightDecrement: normalizeShortcutKey(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 useShortcutSettingsStore = defineStore('shortcut-settings', () => {
|
||||||
|
const shortcuts = reactive<ShortcutSettings>(readStoredSettings());
|
||||||
|
|
||||||
|
const setShortcut = (action: ShortcutAction, value: string) => {
|
||||||
|
const normalized = normalizeShortcutKey(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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import { computed } from 'vue';
|
|||||||
import { useHead } from '@unhead/vue';
|
import { useHead } from '@unhead/vue';
|
||||||
import type { Locale } from '../i18n';
|
import type { Locale } from '../i18n';
|
||||||
import { locale, setLocale, t } from '../i18n';
|
import { locale, setLocale, t } from '../i18n';
|
||||||
|
import type { ShortcutAction } from '../stores/shortcut-settings';
|
||||||
|
import { useShortcutSettingsStore } from '../stores/shortcut-settings';
|
||||||
|
|
||||||
defineOptions({ name: 'SettingsView' });
|
defineOptions({ name: 'SettingsView' });
|
||||||
|
|
||||||
@@ -18,6 +20,19 @@ const selectedLanguage = computed<Locale>({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shortcutSettingsStore = useShortcutSettingsStore();
|
||||||
|
|
||||||
|
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 updateShortcut = (action: ShortcutAction, value: string | number | null) => {
|
||||||
|
shortcutSettingsStore.setShortcut(action, String(value ?? ''));
|
||||||
|
};
|
||||||
|
|
||||||
useHead(() => ({ title: t('settingsTitle') }));
|
useHead(() => ({ title: t('settingsTitle') }));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -33,8 +48,8 @@ useHead(() => ({ title: t('settingsTitle') }));
|
|||||||
<QCard
|
<QCard
|
||||||
flat
|
flat
|
||||||
bordered
|
bordered
|
||||||
class="q-pa-md"
|
class="q-pa-md q-mb-md"
|
||||||
style="max-width: 360px;"
|
style="max-width: 420px;"
|
||||||
>
|
>
|
||||||
<QCardSection class="q-pa-none">
|
<QCardSection class="q-pa-none">
|
||||||
<QSelect
|
<QSelect
|
||||||
@@ -50,5 +65,46 @@ useHead(() => ({ title: t('settingsTitle') }));
|
|||||||
</div>
|
</div>
|
||||||
</QCardSection>
|
</QCardSection>
|
||||||
</QCard>
|
</QCard>
|
||||||
|
|
||||||
|
<QCard
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
class="q-pa-md"
|
||||||
|
style="max-width: 420px;"
|
||||||
|
>
|
||||||
|
<QCardSection class="q-pa-none">
|
||||||
|
<div class="text-subtitle1 q-mb-md">
|
||||||
|
{{ t('settingsShortcutTitle') }}
|
||||||
|
</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]"
|
||||||
|
outlined
|
||||||
|
maxlength="1"
|
||||||
|
:label="field.label"
|
||||||
|
@update:model-value="(value) => updateShortcut(field.action, value)"
|
||||||
|
>
|
||||||
|
<template #hint>
|
||||||
|
{{ field.hint }}
|
||||||
|
</template>
|
||||||
|
</QInput>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<QBtn
|
||||||
|
class="q-mt-md"
|
||||||
|
color="primary"
|
||||||
|
outline
|
||||||
|
:label="t('settingsShortcutReset')"
|
||||||
|
@click="shortcutSettingsStore.resetShortcuts"
|
||||||
|
/>
|
||||||
|
</QCardSection>
|
||||||
|
</QCard>
|
||||||
</QPage>
|
</QPage>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user