14 Commits

Author SHA1 Message Date
Pandipipas 37f9ffb786 feat: add new character images for Mortal Kombat 1 to enhance visual diversity 2026-05-17 15:40:34 +02:00
Pandipipas d76a51c321 feat: add Akali character image for enhanced visual representation 2026-05-17 03:20:40 +02:00
Pandipipas 2ee111a0ca feat: add additional cache directories to .gitignore for improved build management 2026-05-16 19:45:52 +02:00
Pandipipas 7b302e4c21 feat: add new translations for settings and graphics; implement shortcut conflict detection and reset functionality 2026-05-16 14:15:36 +02:00
Pandipipas 6dbf648323 feat: enhance BracketPanel and CommentaryPanel with Twitter handle validation and preview functionality; update i18n for new translations 2026-05-15 15:09:11 +02:00
Pandipipas a4dc89575d feat: add preview functionality to BracketPanel and i18n support for preview text 2026-05-15 02:14:03 +02:00
Pandipipas 4da00508d3 feat: add character game management and player side functionality
- Implemented `useCharacterGame` composable to manage game selection and character state for both players.
- Added `useCountryFilter` composable for filtering country options based on locale.
- Created `usePlayerSide` composable to encapsulate state and handlers for player management on each side of the scoreboard.
- Introduced filtering and input synchronization for player names and countries.
- Enhanced player ID generation to avoid collisions and support custom player entries.
2026-05-14 14:19:44 +02:00
Pandipipas 21d885f6e6 Refactor Dashboard, Graphics, Players, and Settings views for improved layout and styling consistency 2026-05-13 03:20:28 +02:00
Pandipipas d8d3c7f03c Refactor About.vue to simplify collaborator display and remove update checking logic 2026-05-12 18:42:38 +02:00
Pandipipas 7314e73a1b Add scoreko-electron-dev directory to .gitignore 2026-05-09 17:44:57 +02:00
Pandipipas 61e565d358 Fix CI pnpm setup order.
Install pnpm before enabling setup-node pnpm caching so GitHub Actions can resolve the pnpm executable during checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 19:18:06 +02:00
Pandipipas 8040b4fe51 Remove local database file from repository.
Stop tracking the runtime SQLite file and ignore local DB artifacts to prevent accidental commits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 19:13:33 +02:00
Pandipipas 91a8ce730c Migrate project setup to Node 24 runtime.
Update Node and TypeScript toolchain references, CI node version, lockfile resolution, and include current workspace/runtime files for consistency.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 19:11:27 +02:00
Pandipipas 7a5c1ec637 Migrate package management from npm to pnpm.
Standardize local and CI workflows on pnpm 11.0.8, replace npm commands in docs/config, and swap lockfiles for reproducible installs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-08 18:46:55 +02:00
61 changed files with 10327 additions and 13917 deletions
+10 -5
View File
@@ -23,17 +23,22 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 11.0.8
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 24
cache: npm cache: pnpm
- name: Install dependencies - name: Install dependencies
run: npm ci run: pnpm install --frozen-lockfile
- name: Lint - name: Lint
run: npm run lint run: pnpm run lint
- name: Build - name: Build
run: npm run build run: pnpm run build
+12 -4
View File
@@ -1,7 +1,7 @@
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* pnpm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* lerna-debug.log*
@@ -48,8 +48,11 @@ web_modules/
# TypeScript cache # TypeScript cache
*.tsbuildinfo *.tsbuildinfo
# Optional npm cache directory # Optional pnpm cache directory
.npm .pnpm-store
.corepack/
.npm-cache/
.node-gyp/
# Optional eslint cache # Optional eslint cache
.eslintcache .eslintcache
@@ -66,7 +69,7 @@ web_modules/
# Optional REPL history # Optional REPL history
.node_repl_history .node_repl_history
# Output of 'npm pack' # Output of 'pnpm pack'
*.tgz *.tgz
# Yarn Integrity file # Yarn Integrity file
@@ -134,3 +137,8 @@ dist
/extension/ /extension/
/graphics/ /graphics/
/shared/dist/ /shared/dist/
# Local runtime database
/db/
*.sqlite3
/scoreko-electron-dev/
+6 -6
View File
@@ -14,12 +14,12 @@ NodeCG bundle for producing fighting game overlays.
## Scripts ## Scripts
- `npm run autofix`: automatically fixes lint errors. - `pnpm run autofix`: automatically fixes lint errors.
- `npm run build`: builds dashboard/graphics and extension. - `pnpm run build`: builds dashboard/graphics and extension.
- `npm run lint`: validates project linting. - `pnpm run lint`: validates project linting.
- `npm run schema-types`: generates types from schemas. - `pnpm run schema-types`: generates types from schemas.
- `npm run start`: starts NodeCG. - `pnpm run start`: starts NodeCG.
- `npm run watch`: development mode with watch. - `pnpm run watch`: development mode with watch.
## Version ## Version
-12277
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -12,6 +12,7 @@
}, },
"license": "MIT", "license": "MIT",
"author": "Pandipipas", "author": "Pandipipas",
"packageManager": "pnpm@11.0.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"autofix": "eslint --fix", "autofix": "eslint --fix",
@@ -26,8 +27,8 @@
"@eslint/js": "^9.39.0", "@eslint/js": "^9.39.0",
"@quasar/extras": "^1.17.0", "@quasar/extras": "^1.17.0",
"@quasar/vite-plugin": "^1.10.0", "@quasar/vite-plugin": "^1.10.0",
"@tsconfig/node22": "^22.0.2", "@tsconfig/node24": "^24.0.0",
"@types/node": "^22.18.13", "@types/node": "^24.0.0",
"@unhead/vue": "^2.0.19", "@unhead/vue": "^2.0.19",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-typescript": "^14.6.0", "@vue/eslint-config-typescript": "^14.6.0",
+8203
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
allowBuilds:
'@parcel/watcher': true
'@vaadin/vaadin-usage-statistics': true
better-sqlite3: true
esbuild: true
msgpackr-extract: true
vue-demi: true
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { t } from '../i18n'; import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../stores/scoreboard';
const scoreboardStore = useScoreboardStore(); const scoreboardStore = useScoreboardStore();
let customDeactivateTimer: ReturnType<typeof setTimeout> | null = null;
const stageOptions = [ const stageOptions = [
'pools', 'pools',
'top 128', 'top 128',
@@ -25,7 +27,7 @@ const stageOptions = [
const bracketSideOptions = [ const bracketSideOptions = [
{ label: 'None', value: '' }, { label: 'None', value: '' },
{ label: 'Winners', value: 'Winners' }, { label: 'Winners', value: 'Winners' },
{ label: 'Loosers', value: 'Loosers' }, { label: 'Losers', value: 'Losers' },
]; ];
const stage = ref(stageOptions[0]); const stage = ref(stageOptions[0]);
@@ -34,6 +36,14 @@ const customActive = ref(false);
const customText = ref(''); const customText = ref('');
const hasChanges = ref(false); const hasChanges = ref(false);
const previewText = computed(() => {
if (customActive.value) {
return customText.value.trim() || '—';
}
const prefix = bracketSide.value ? `${bracketSide.value} ` : '';
return `${prefix}${stage.value}`.trim() || '—';
});
const parseInitialRound = () => { const parseInitialRound = () => {
const round = scoreboardStore.scoreboard.round.trim(); const round = scoreboardStore.scoreboard.round.trim();
if (!round) { if (!round) {
@@ -90,8 +100,14 @@ watch(customActive, (value) => {
}); });
watch(customText, (value) => { watch(customText, (value) => {
if (customDeactivateTimer) {
clearTimeout(customDeactivateTimer);
}
if (!value.trim()) { if (!value.trim()) {
customActive.value = false; customDeactivateTimer = setTimeout(() => {
customActive.value = false;
customDeactivateTimer = null;
}, 600);
} }
}); });
@@ -112,6 +128,7 @@ onMounted(() => {
v-model="stage" v-model="stage"
:label="t('bracketStage')" :label="t('bracketStage')"
:options="stageOptions" :options="stageOptions"
:disable="customActive"
dense dense
class="bracket-panel__field" class="bracket-panel__field"
/> />
@@ -119,6 +136,7 @@ onMounted(() => {
v-model="bracketSide" v-model="bracketSide"
:label="t('bracketSide')" :label="t('bracketSide')"
:options="bracketSideOptions" :options="bracketSideOptions"
:disable="customActive"
dense dense
emit-value emit-value
map-options map-options
@@ -129,6 +147,7 @@ onMounted(() => {
v-model="customText" v-model="customText"
:label="t('bracketCustomProgress')" :label="t('bracketCustomProgress')"
dense dense
clearable
class="bracket-panel-custom-input bracket-panel__field" class="bracket-panel-custom-input bracket-panel__field"
/> />
<QToggle <QToggle
@@ -138,6 +157,17 @@ onMounted(() => {
class="bracket-panel-custom-toggle" class="bracket-panel-custom-toggle"
/> />
</div> </div>
<!-- Preview -->
<div class="bracket-panel__preview">
<span class="bracket-panel__preview-label">{{ t('bracketPreview') }}</span>
<span
class="bracket-panel__preview-text"
:class="{ 'bracket-panel__preview-text--custom': customActive }"
>
{{ previewText }}
</span>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -183,4 +213,36 @@ onMounted(() => {
.bracket-panel__field :deep(.q-field__label) { .bracket-panel__field :deep(.q-field__label) {
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} }
.bracket-panel__preview {
display: flex;
align-items: baseline;
gap: 10px;
padding: 8px 10px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
}
.bracket-panel__preview-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.45);
white-space: nowrap;
flex-shrink: 0;
}
.bracket-panel__preview-text {
font-size: 1rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.92);
letter-spacing: 0.02em;
word-break: break-word;
transition: color 0.2s ease;
}
.bracket-panel__preview-text--custom {
color: var(--q-secondary, #26a69a);
}
</style> </style>
@@ -1,8 +1,65 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { t } from '../i18n'; import { t } from '../i18n';
import { useCommentaryStore } from '../stores/commentary'; import { useCommentaryStore } from '../stores/commentary';
const commentaryStore = useCommentaryStore(); const commentaryStore = useCommentaryStore();
// --- Twitter handle helpers ---
const TWITTER_MAX_LENGTH = 15;
const TWITTER_VALID_CHARS = /^[A-Za-z0-9_]*$/;
const twitterRules = [
(val: string) =>
!val || val.length <= TWITTER_MAX_LENGTH || t('commentaryTwitterMaxLength'),
(val: string) =>
!val || TWITTER_VALID_CHARS.test(val) || t('commentaryTwitterInvalidChars'),
];
function stripAt(value: string): string {
return value.startsWith('@') ? value.slice(1) : value;
}
function handleLeftTwitterInput(value: string | number | null) {
commentaryStore.leftCommentatorTwitter = value ? stripAt(String(value)) : '';
}
function handleRightTwitterInput(value: string | number | null) {
commentaryStore.rightCommentatorTwitter = value ? stripAt(String(value)) : '';
}
// --- Clear ---
function clearAll() {
commentaryStore.leftCommentator = '';
commentaryStore.leftCommentatorTwitter = '';
commentaryStore.rightCommentator = '';
commentaryStore.rightCommentatorTwitter = '';
}
const isAnythingFilled = computed(() =>
!!(
commentaryStore.leftCommentator ||
commentaryStore.leftCommentatorTwitter ||
commentaryStore.rightCommentator ||
commentaryStore.rightCommentatorTwitter
)
);
// --- Handle preview ---
const leftHandlePreview = computed(() =>
commentaryStore.leftCommentatorTwitter
? `@${commentaryStore.leftCommentatorTwitter}`
: ''
);
const rightHandlePreview = computed(() =>
commentaryStore.rightCommentatorTwitter
? `@${commentaryStore.rightCommentatorTwitter}`
: ''
);
</script> </script>
<template> <template>
@@ -14,7 +71,9 @@ const commentaryStore = useCommentaryStore();
</div> </div>
<div class="commentary-panel__layout"> <div class="commentary-panel__layout">
<!-- Commentator 1 -->
<div class="commentary-panel__commentator"> <div class="commentary-panel__commentator">
<QInput <QInput
v-model="commentaryStore.leftCommentator" v-model="commentaryStore.leftCommentator"
:label="t('commentaryCommentator1')" :label="t('commentaryCommentator1')"
@@ -27,23 +86,58 @@ const commentaryStore = useCommentaryStore();
</QInput> </QInput>
<QInput <QInput
v-model="commentaryStore.leftCommentatorTwitter" :model-value="commentaryStore.leftCommentatorTwitter"
:label="t('commentaryTwitterText')" :label="t('commentaryTwitterText')"
:rules="twitterRules"
:maxlength="TWITTER_MAX_LENGTH"
dense dense
class="commentary-panel__field" class="commentary-panel__field"
@update:model-value="handleLeftTwitterInput"
/> />
<Transition name="commentary-panel__preview">
<div
v-if="leftHandlePreview"
class="commentary-panel__handle-preview"
>
{{ leftHandlePreview }}
</div>
</Transition>
</div> </div>
<QBtn <!-- Center controls -->
flat <div class="commentary-panel__center-controls">
dense <QBtn
round flat
icon="swap_horiz" dense
class="commentary-panel__swap-btn" round
@click="commentaryStore.swapCommentators" icon="swap_horiz"
/> class="commentary-panel__swap-btn"
@click="commentaryStore.swapCommentators"
>
<QTooltip anchor="top middle" self="bottom middle">
{{ t('commentarySwap') }}
</QTooltip>
</QBtn>
<QBtn
flat
dense
round
icon="restart_alt"
class="commentary-panel__clear-btn"
:disable="!isAnythingFilled"
@click="clearAll"
>
<QTooltip anchor="top middle" self="bottom middle">
{{ t('commentaryClear') }}
</QTooltip>
</QBtn>
</div>
<!-- Commentator 2 -->
<div class="commentary-panel__commentator"> <div class="commentary-panel__commentator">
<QInput <QInput
v-model="commentaryStore.rightCommentator" v-model="commentaryStore.rightCommentator"
:label="t('commentaryCommentator2')" :label="t('commentaryCommentator2')"
@@ -56,11 +150,23 @@ const commentaryStore = useCommentaryStore();
</QInput> </QInput>
<QInput <QInput
v-model="commentaryStore.rightCommentatorTwitter" :model-value="commentaryStore.rightCommentatorTwitter"
:label="t('commentaryTwitterText')" :label="t('commentaryTwitterText')"
:rules="twitterRules"
:maxlength="TWITTER_MAX_LENGTH"
dense dense
class="commentary-panel__field" class="commentary-panel__field"
@update:model-value="handleRightTwitterInput"
/> />
<Transition name="commentary-panel__preview">
<div
v-if="rightHandlePreview"
class="commentary-panel__handle-preview"
>
{{ rightHandlePreview }}
</div>
</Transition>
</div> </div>
</div> </div>
</div> </div>
@@ -89,6 +195,35 @@ const commentaryStore = useCommentaryStore();
gap: 2px; gap: 2px;
} }
/* Center controls column */
.commentary-panel__center-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.commentary-panel__clear-btn {
color: rgba(255, 255, 255, 0.45);
transition: color 0.2s ease;
}
.commentary-panel__clear-btn:not(:disabled):hover {
color: rgba(255, 255, 255, 0.9);
}
/* Swap button */
.commentary-panel__swap-btn {
color: #fff;
opacity: 0.85;
}
.commentary-panel__swap-btn:hover {
opacity: 1;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.45);
}
/* Fields */
.commentary-panel__field :deep(.q-field__control) { .commentary-panel__field :deep(.q-field__control) {
min-height: 28px; min-height: 28px;
padding: 0; padding: 0;
@@ -112,14 +247,27 @@ const commentaryStore = useCommentaryStore();
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} }
.commentary-panel__swap-btn { /* Handle preview */
color: #fff; .commentary-panel__handle-preview {
opacity: 0.85; margin-top: 4px;
font-size: 0.72rem;
color: rgba(255, 255, 255, 0.45);
letter-spacing: 0.02em;
padding-left: 2px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
} }
.commentary-panel__swap-btn:hover { .commentary-panel__preview-enter-active,
opacity: 1; .commentary-panel__preview-leave-active {
text-shadow: 0 0 10px rgba(255, 255, 255, 0.45); transition: opacity 0.2s ease, transform 0.2s ease;
}
.commentary-panel__preview-enter-from,
.commentary-panel__preview-leave-to {
opacity: 0;
transform: translateY(-4px);
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -127,8 +275,9 @@ const commentaryStore = useCommentaryStore();
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.commentary-panel__swap-btn { .commentary-panel__center-controls {
justify-self: center; justify-self: center;
flex-direction: row;
} }
} }
</style> </style>
@@ -0,0 +1,503 @@
<script setup lang="ts">
import { computed, inject } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { usePlayerSide } from '../composables/usePlayerSide';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { t } from '../i18n';
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
const props = defineProps<{
side: 'left' | 'right';
}>();
// ---------------------------------------------------------------------------
// Store & composables
// ---------------------------------------------------------------------------
const scoreboardStore = useScoreboardStore();
const player = usePlayerSide(props.side);
const {
leftCharacterOptions,
rightCharacterOptions,
leftCharacterInput,
rightCharacterInput,
leftCharacterImage,
rightCharacterImage,
onLeftCharacterFilter,
onRightCharacterFilter,
} = inject(CHARACTER_GAME_KEY)!;
// ---------------------------------------------------------------------------
// Side-specific derivations from the injected character game state
// ---------------------------------------------------------------------------
const isLeft = computed(() => props.side === 'left');
/**
* Character options for this side. Refs from the shared composable are
* accessed directly so Vue's reactivity tracks them correctly.
*/
const characterOptions = computed(() =>
isLeft.value ? leftCharacterOptions.value : rightCharacterOptions.value,
);
/**
* Two-way binding for QSelect's input-value (the display text).
* The setter writes back into the shared composable's ref so that watchers
* in useCharacterGame can update the cache correctly.
*/
const characterInputValue = computed({
get: () => (isLeft.value ? leftCharacterInput.value : rightCharacterInput.value),
set: (v) => {
if (isLeft.value) leftCharacterInput.value = v;
else rightCharacterInput.value = v;
},
});
const panelImage = computed(() =>
isLeft.value ? leftCharacterImage.value : rightCharacterImage.value,
);
const onCharacterFilter = computed(() =>
isLeft.value ? onLeftCharacterFilter : onRightCharacterFilter,
);
/**
* Two-way binding for the character value stored in the scoreboard.
*/
const character = computed({
get: () => (isLeft.value
? scoreboardStore.scoreboard.leftCharacter
: scoreboardStore.scoreboard.rightCharacter),
set: (v) => {
if (isLeft.value) scoreboardStore.scoreboard.leftCharacter = v;
else scoreboardStore.scoreboard.rightCharacter = v;
},
});
// ---------------------------------------------------------------------------
// i18n helpers resolved at runtime so the side label is correct
// ---------------------------------------------------------------------------
const sideLabel = computed(() => t(isLeft.value ? 'scoreboardLeft' : 'scoreboardRight'));
const sideImageLabel = computed(() => t(isLeft.value ? 'scoreboardLeftImage' : 'scoreboardRightImage'));
</script>
<template>
<!--
Left layout: [image-column | controls]
Right layout: [controls | image-column]
The DOM order differs between sides intentionally so that the character
image always appears on the outer edge and the controls face the center.
CSS column widths are flipped via .scoreboard-preview__side--right.
-->
<div
class="scoreboard-preview__side"
:class="{ 'scoreboard-preview__side--right': !isLeft }"
>
<div class="scoreboard-preview__side-inner">
<!-- LEFT: image first, then controls -->
<template v-if="isLeft">
<!-- Character image + character selector -->
<div class="scoreboard-preview__image-column">
<div class="scoreboard-preview__image-wrap">
<img
v-if="panelImage"
:src="panelImage"
:alt="`${player.displayName.value || sideLabel} ${t('scoreboardPreview')}`"
class="scoreboard-preview__image"
>
<div
v-else
class="scoreboard-preview__empty"
>
{{ sideImageLabel }}
</div>
</div>
<QSelect
v-model="character"
v-model:input-value="characterInputValue"
:options="characterOptions"
option-value="value"
option-label="label"
emit-value
map-options
:label="t('scoreboardLabelCharacter')"
dense
use-input
input-debounce="0"
hide-selected
fill-input
clearable
class="scoreboard-preview__field scoreboard-preview__character-field"
:disable="!scoreboardStore.scoreboard.game"
@filter="onCharacterFilter"
>
<template #prepend>
<QIcon name="sports_martial_arts" />
</template>
</QSelect>
</div>
<!-- Player / team / country controls -->
<div class="scoreboard-preview__controls">
<QSelect
v-model="player.playerId.value"
v-model:input-value="player.inputValue.value"
:options="player.playerOptions.value"
:label="t('scoreboardLabelPlayer')"
dense
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
options-dense
class="scoreboard-preview__field"
@filter="player.onFilter"
@focus="player.onFocus"
@blur="player.onBlur"
@update:model-value="player.onSelect"
>
<template #prepend>
<QIcon name="person" />
</template>
<template #append>
<QBtn
v-if="player.showsNameSave.value"
flat
round
dense
icon="save"
color="primary"
@click.stop="player.onNameSave"
/>
</template>
</QSelect>
<QInput
v-model="player.teamOverride.value"
:label="t('scoreboardLabelTeam')"
dense
class="scoreboard-preview__field"
>
<template #prepend>
<QIcon name="groups" />
</template>
<template #append>
<QBtn
v-if="player.teamChanged.value"
flat
round
dense
icon="save"
color="primary"
@click.stop="player.saveTeamChange"
/>
</template>
</QInput>
<QSelect
v-model="player.countryOverride.value"
v-model:input-value="player.countryInput.value"
:options="player.filteredCountryOptions.value"
option-value="value"
option-label="label"
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
clearable
:label="t('scoreboardLabelCountry')"
dense
class="scoreboard-preview__field"
@filter="player.onCountryFilter"
>
<template #prepend>
<QIcon name="flag" />
</template>
<template #append>
<QBtn
v-if="player.countryChanged.value"
flat
round
dense
icon="save"
color="primary"
@click.stop="player.saveCountryChange"
/>
</template>
</QSelect>
</div>
</template>
<!-- RIGHT: controls first, then image -->
<template v-else>
<!-- Player / team / country controls -->
<div class="scoreboard-preview__controls">
<QSelect
v-model="player.playerId.value"
v-model:input-value="player.inputValue.value"
:options="player.playerOptions.value"
:label="t('scoreboardLabelPlayer')"
dense
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
options-dense
class="scoreboard-preview__field"
@filter="player.onFilter"
@focus="player.onFocus"
@blur="player.onBlur"
@update:model-value="player.onSelect"
>
<template #prepend>
<QIcon name="person" />
</template>
<template #append>
<QBtn
v-if="player.showsNameSave.value"
flat
round
dense
icon="save"
color="primary"
@click.stop="player.onNameSave"
/>
</template>
</QSelect>
<QInput
v-model="player.teamOverride.value"
:label="t('scoreboardLabelTeam')"
dense
class="scoreboard-preview__field"
>
<template #prepend>
<QIcon name="groups" />
</template>
<template #append>
<QBtn
v-if="player.teamChanged.value"
flat
round
dense
icon="save"
color="primary"
@click.stop="player.saveTeamChange"
/>
</template>
</QInput>
<QSelect
v-model="player.countryOverride.value"
v-model:input-value="player.countryInput.value"
:options="player.filteredCountryOptions.value"
option-value="value"
option-label="label"
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
clearable
:label="t('scoreboardLabelCountry')"
dense
class="scoreboard-preview__field"
@filter="player.onCountryFilter"
>
<template #prepend>
<QIcon name="flag" />
</template>
<template #append>
<QBtn
v-if="player.countryChanged.value"
flat
round
dense
icon="save"
color="primary"
@click.stop="player.saveCountryChange"
/>
</template>
</QSelect>
</div>
<!-- Character image + character selector -->
<div class="scoreboard-preview__image-column">
<div class="scoreboard-preview__image-wrap">
<img
v-if="panelImage"
:src="panelImage"
:alt="`${player.displayName.value || sideLabel} ${t('scoreboardPreview')}`"
class="scoreboard-preview__image"
>
<div
v-else
class="scoreboard-preview__empty"
>
{{ sideImageLabel }}
</div>
</div>
<QSelect
v-model="character"
v-model:input-value="characterInputValue"
:options="characterOptions"
option-value="value"
option-label="label"
emit-value
map-options
:label="t('scoreboardLabelCharacter')"
dense
use-input
input-debounce="0"
hide-selected
fill-input
clearable
class="scoreboard-preview__field scoreboard-preview__character-field"
:disable="!scoreboardStore.scoreboard.game"
@filter="onCharacterFilter"
>
<template #prepend>
<QIcon name="sports_martial_arts" />
</template>
</QSelect>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.scoreboard-preview__side {
display: flex;
align-items: center;
}
.scoreboard-preview__side-inner {
width: 100%;
display: grid;
grid-template-columns: minmax(220px, 320px) minmax(180px, 1fr);
align-items: center;
gap: 14px;
}
.scoreboard-preview__side--right {
text-align: right;
}
.scoreboard-preview__side--right .scoreboard-preview__side-inner {
grid-template-columns: minmax(180px, 1fr) minmax(220px, 320px);
}
.scoreboard-preview__image-column {
width: min(100%, 320px);
display: flex;
flex-direction: column;
gap: 8px;
}
.scoreboard-preview__side .scoreboard-preview__image-column {
justify-self: start;
}
.scoreboard-preview__side--right .scoreboard-preview__image-column {
justify-self: end;
}
.scoreboard-preview__image-wrap {
position: relative;
width: min(100%, 320px);
aspect-ratio: 4 / 4;
overflow: visible;
contain: layout;
}
.scoreboard-preview__image {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
transform: scale(1.5);
transform-origin: center center;
pointer-events: none;
}
.scoreboard-preview__empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.65);
font-weight: 600;
}
.scoreboard-preview__controls {
width: min(100%, 260px);
justify-self: center;
display: flex;
flex-direction: column;
gap: 6px;
}
.scoreboard-preview__field {
margin: 0;
}
.scoreboard-preview__character-field {
margin-top: 2px;
}
.scoreboard-preview__field :deep(.q-field__control) {
min-height: 28px;
padding: 0;
background: transparent !important;
border-radius: 0;
}
.scoreboard-preview__field :deep(.q-field__control:before),
.scoreboard-preview__field :deep(.q-field__control:after) {
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
}
.scoreboard-preview__field :deep(.q-field__native),
.scoreboard-preview__field :deep(.q-field__input),
.scoreboard-preview__field :deep(.q-field__label) {
color: rgba(255, 255, 255, 0.92);
}
@media (max-width: 900px) {
.scoreboard-preview__image-wrap {
width: min(100%, 280px);
}
.scoreboard-preview__side-inner {
grid-template-columns: 1fr;
align-items: flex-start;
justify-items: flex-start;
}
.scoreboard-preview__side--right {
text-align: left;
}
.scoreboard-preview__side--right .scoreboard-preview__side-inner {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { inject } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { t } from '../i18n';
const scoreboardStore = useScoreboardStore();
const { gameInput, fightingGameOptions, onGameFilter } = inject(CHARACTER_GAME_KEY)!;
const adjustLeftScore = (delta: number) => {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta);
};
const adjustRightScore = (delta: number) => {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta);
};
</script>
<template>
<div class="scoreboard-preview__center">
<QSelect
v-model="scoreboardStore.scoreboard.game"
v-model:input-value="gameInput"
:options="fightingGameOptions"
:label="t('scoreboardLabelGame')"
dense
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
class="scoreboard-preview__field scoreboard-preview__game-field"
@filter="onGameFilter"
>
<template #prepend>
<QIcon name="sports_esports" />
</template>
</QSelect>
<div class="scoreboard-preview__score-controls">
<div class="scoreboard-preview__score-side">
<QBtn
flat
dense
round
size="sm"
icon="add"
@click="adjustLeftScore(1)"
/>
<span class="scoreboard-preview__score-value">
{{ scoreboardStore.scoreboard.leftScore }}
</span>
<QBtn
flat
dense
round
size="sm"
icon="remove"
@click="adjustLeftScore(-1)"
/>
</div>
<span class="scoreboard-preview__dash">-</span>
<div class="scoreboard-preview__score-side">
<QBtn
flat
dense
round
size="sm"
icon="add"
@click="adjustRightScore(1)"
/>
<span class="scoreboard-preview__score-value">
{{ scoreboardStore.scoreboard.rightScore }}
</span>
<QBtn
flat
dense
round
size="sm"
icon="remove"
@click="adjustRightScore(-1)"
/>
</div>
</div>
<div class="scoreboard-preview__actions">
<QBtn
flat
dense
icon="swap_horiz"
class="scoreboard-preview__action-btn"
@click="scoreboardStore.swapPlayers"
/>
<QBtn
flat
dense
icon="restart_alt"
class="scoreboard-preview__action-btn"
@click="scoreboardStore.resetScores"
/>
</div>
</div>
</template>
<style scoped>
.scoreboard-preview__center {
display: flex;
flex-direction: column;
align-items: center;
align-self: stretch;
justify-content: flex-start;
padding-top: 2px;
gap: 10px;
}
.scoreboard-preview__game-field {
width: min(100%, 240px);
margin-bottom: 56px;
}
.scoreboard-preview__score-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
}
.scoreboard-preview__score-side {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.scoreboard-preview__score-value {
min-width: 64px;
text-align: center;
font-size: clamp(4rem, 7vw, 5.6rem);
font-weight: 800;
line-height: 1;
}
.scoreboard-preview__dash {
opacity: 0.7;
font-size: clamp(3rem, 5vw, 4rem);
font-weight: 700;
}
.scoreboard-preview__actions {
display: flex;
align-items: center;
gap: 10px;
}
.scoreboard-preview__action-btn {
color: #fff;
opacity: 0.85;
}
.scoreboard-preview__action-btn:hover {
opacity: 1;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.45);
}
/* Shared field styles (used by QSelect inside this panel) */
.scoreboard-preview__field {
margin: 0;
}
.scoreboard-preview__field :deep(.q-field__control) {
min-height: 28px;
padding: 0;
background: transparent !important;
border-radius: 0;
}
.scoreboard-preview__field :deep(.q-field__control:before),
.scoreboard-preview__field :deep(.q-field__control:after) {
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
}
.scoreboard-preview__field :deep(.q-field__native),
.scoreboard-preview__field :deep(.q-field__input),
.scoreboard-preview__field :deep(.q-field__label) {
color: rgba(255, 255, 255, 0.92);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,205 @@
import { computed, ref, watch, type InjectionKey, type Ref } from 'vue';
import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters';
import { useScoreboardStore } from '../stores/scoreboard';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const ALL_FIGHTING_GAME_OPTIONS = [
'2XKO',
'Mortal Kombat 1',
'Street Fighter 6',
'TEKKEN 8',
'Guilty Gear -Strive-',
'THE KING OF FIGHTERS XV',
].map((game) => ({ label: game, value: game }));
export type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
// ---------------------------------------------------------------------------
// Injection key (type-safe provide/inject)
// ---------------------------------------------------------------------------
export type CharacterGameContext = ReturnType<typeof useCharacterGame>;
export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame');
// ---------------------------------------------------------------------------
// Composable
// ---------------------------------------------------------------------------
/**
* Manages game selection and character state for both sides.
* Must be called ONCE in the parent (ScoreboardPanel) and provided via
* CHARACTER_GAME_KEY so both PlayerSidePanel instances share the same state.
*/
export function useCharacterGame() {
const scoreboardStore = useScoreboardStore();
// Game selector
const gameInput = ref('');
const fightingGameOptions = ref(ALL_FIGHTING_GAME_OPTIONS);
// Per-side character state
const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game));
const leftCharacterOptions = ref<CharacterOption[]>([]);
const rightCharacterOptions = ref<CharacterOption[]>([]);
const leftCharacterInput = ref('');
const rightCharacterInput = ref('');
// Remembers selected characters per game so swapping games restores them
const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({});
// Character images for preview
const leftCharacterImage = computed(() => {
const match = characterOptions.value.find(
(o) => o.value === scoreboardStore.scoreboard.leftCharacter,
);
return match?.image ?? '';
});
const rightCharacterImage = computed(() => {
const match = characterOptions.value.find(
(o) => o.value === scoreboardStore.scoreboard.rightCharacter,
);
return match?.image ?? '';
});
// ---------------------------------------------------------------------------
// Filter handlers
// ---------------------------------------------------------------------------
const onGameFilter = (value: string, update: (fn: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
fightingGameOptions.value = needle
? ALL_FIGHTING_GAME_OPTIONS.filter((g) => g.label.toLowerCase().includes(needle))
: ALL_FIGHTING_GAME_OPTIONS;
});
};
const makeCharacterFilter = (target: Ref<CharacterOption[]>) =>
(value: string, update: (fn: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
target.value = needle
? characterOptions.value.filter((c) => c.label.toLowerCase().includes(needle))
: characterOptions.value;
});
};
const onLeftCharacterFilter = makeCharacterFilter(leftCharacterOptions);
const onRightCharacterFilter = makeCharacterFilter(rightCharacterOptions);
// ---------------------------------------------------------------------------
// Watchers
// ---------------------------------------------------------------------------
// Keep gameInput display value in sync
watch(
() => scoreboardStore.scoreboard.game,
(value) => {
const match = ALL_FIGHTING_GAME_OPTIONS.find((o) => o.value === value);
gameInput.value = match?.label ?? '';
},
{ immediate: true },
);
// Handle game change: persist previous characters, restore or apply defaults
watch(
() => scoreboardStore.scoreboard.game,
(newGame, previousGame) => {
if (previousGame) {
charactersByGame.value[previousGame] = {
leftCharacter: scoreboardStore.scoreboard.leftCharacter,
rightCharacter: scoreboardStore.scoreboard.rightCharacter,
};
}
const options = getCharactersByGame(newGame);
leftCharacterOptions.value = options;
rightCharacterOptions.value = options;
const allowed = new Set(options.map((o) => o.value));
const saved = newGame ? charactersByGame.value[newGame] : undefined;
const { leftCharacter: curLeft, rightCharacter: curRight } = scoreboardStore.scoreboard;
let nextLeft = saved?.leftCharacter ?? curLeft;
let nextRight = saved?.rightCharacter ?? curRight;
if (!allowed.has(nextLeft)) nextLeft = '';
if (!allowed.has(nextRight)) nextRight = '';
// Apply defaults only when neither side had a character yet
if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) {
const defaults = getDefaultCharactersByGame(newGame);
if (defaults) {
if (!nextLeft) nextLeft = allowed.has(defaults.leftCharacter) ? defaults.leftCharacter : '';
if (!nextRight) nextRight = allowed.has(defaults.rightCharacter) ? defaults.rightCharacter : '';
}
}
if (allowed.has(nextLeft)) {
scoreboardStore.scoreboard.leftCharacter = nextLeft;
} else if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) {
scoreboardStore.scoreboard.leftCharacter = '';
leftCharacterInput.value = '';
}
if (allowed.has(nextRight)) {
scoreboardStore.scoreboard.rightCharacter = nextRight;
} else if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) {
scoreboardStore.scoreboard.rightCharacter = '';
rightCharacterInput.value = '';
}
},
{ immediate: true },
);
// Keep left character display input and charactersByGame cache in sync
watch(
() => scoreboardStore.scoreboard.leftCharacter,
(value) => {
const match = characterOptions.value.find((o) => o.value === value);
leftCharacterInput.value = match?.label ?? '';
const game = scoreboardStore.scoreboard.game;
if (game) {
charactersByGame.value[game] = {
leftCharacter: value,
rightCharacter: scoreboardStore.scoreboard.rightCharacter,
};
}
},
{ immediate: true },
);
// Keep right character display input and charactersByGame cache in sync
watch(
() => scoreboardStore.scoreboard.rightCharacter,
(value) => {
const match = characterOptions.value.find((o) => o.value === value);
rightCharacterInput.value = match?.label ?? '';
const game = scoreboardStore.scoreboard.game;
if (game) {
charactersByGame.value[game] = {
leftCharacter: scoreboardStore.scoreboard.leftCharacter,
rightCharacter: value,
};
}
},
{ immediate: true },
);
return {
gameInput,
fightingGameOptions,
leftCharacterOptions,
rightCharacterOptions,
leftCharacterInput,
rightCharacterInput,
leftCharacterImage,
rightCharacterImage,
onGameFilter,
onLeftCharacterFilter,
onRightCharacterFilter,
};
}
@@ -0,0 +1,36 @@
import { computed, ref, watch } from 'vue';
import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
import { locale } from '../i18n';
/**
* Manages filtered country options and the display input value
* for a single side of the scoreboard.
*
* @param getOverride - Getter returning the current country code override
*/
export function useCountryFilter(getOverride: () => string) {
const countryOptions = computed(() => getCountryOptions(locale.value));
const countryInput = ref('');
const filteredOptions = ref(countryOptions.value);
// Keep filtered list in sync when locale changes
watch(countryOptions, (opts) => {
filteredOptions.value = opts;
});
// Keep display input in sync with the stored country code
watch(getOverride, (value) => {
countryInput.value = getCountryLabel(value, locale.value);
}, { immediate: true });
const onFilter = (value: string, update: (fn: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
filteredOptions.value = needle
? countryOptions.value.filter((c) => c.label.toLowerCase().includes(needle))
: countryOptions.value;
});
};
return { countryInput, filteredOptions, onFilter };
}
@@ -0,0 +1,346 @@
import { computed, ref, watch, watchEffect } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { usePlayersStore } from '../stores/players';
import type { Schemas } from '../../../types';
import { t } from '../i18n';
import { useCountryFilter } from './useCountryFilter';
// ---------------------------------------------------------------------------
// Constants (exported so components can compare against them)
// ---------------------------------------------------------------------------
export const CUSTOM_LEFT_PLAYER_ID = '__custom_left_player__';
export const CUSTOM_RIGHT_PLAYER_ID = '__custom_right_player__';
// ---------------------------------------------------------------------------
// Pure helpers (no Vue reactivity)
// ---------------------------------------------------------------------------
const normalizeName = (value: string) => value.trim().toLowerCase();
/**
* Generates a unique slug-based player ID that does not collide with
* existing player keys in the store.
*/
const createPlayerId = (name: string, players: Schemas.Players): string => {
const base = name
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[^\w\s-]/g, '')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-') || 'player';
let index = 1;
let candidate = base;
while (players[candidate]) {
index += 1;
candidate = `${base}-${index}`;
}
return candidate;
};
// ---------------------------------------------------------------------------
// Composable
// ---------------------------------------------------------------------------
/**
* Encapsulates all reactive state and handlers for one side of the scoreboard
* (left or right). Call once per side inside the corresponding component.
*/
export function usePlayerSide(side: 'left' | 'right') {
const scoreboardStore = useScoreboardStore();
const playersStore = usePlayersStore();
const isLeft = side === 'left';
const CUSTOM_ID = isLeft ? CUSTOM_LEFT_PLAYER_ID : CUSTOM_RIGHT_PLAYER_ID;
// ---------------------------------------------------------------------------
// Two-way computed bindings to the store (avoids left/right if-chains in
// the template and keeps mutation contained to the composable)
// ---------------------------------------------------------------------------
const playerId = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftPlayerId : scoreboardStore.scoreboard.rightPlayerId),
set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftPlayerId = v;
else scoreboardStore.scoreboard.rightPlayerId = v;
},
});
const nameOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftNameOverride : scoreboardStore.scoreboard.rightNameOverride),
set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftNameOverride = v;
else scoreboardStore.scoreboard.rightNameOverride = v;
},
});
const teamOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftTeamOverride : scoreboardStore.scoreboard.rightTeamOverride),
set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftTeamOverride = v;
else scoreboardStore.scoreboard.rightTeamOverride = v;
},
});
const countryOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftCountryOverride : scoreboardStore.scoreboard.rightCountryOverride),
set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftCountryOverride = v;
else scoreboardStore.scoreboard.rightCountryOverride = v;
},
});
// ---------------------------------------------------------------------------
// UI state
// ---------------------------------------------------------------------------
const filter = ref('');
const inputValue = ref('');
const focused = ref(false);
// Country filter (delegated to sub-composable)
const {
countryInput,
filteredOptions: filteredCountryOptions,
onFilter: onCountryFilter,
} = useCountryFilter(() => countryOverride.value);
// ---------------------------------------------------------------------------
// Player options
// ---------------------------------------------------------------------------
const allPlayerOptions = computed(() => {
const base = [{ label: t('scoreboardUnassigned'), value: '' }];
const entries = Object.entries(playersStore.players) as [string, Schemas.Players[string]][];
const mapped = entries.map(([id, player]) => ({
value: id,
label: player.gamertag || id,
}));
return base.concat(mapped);
});
/**
* Player options filtered by the current search input.
* Prepends the custom player entry when the user has typed a new name.
*/
const playerOptions = computed(() => {
const needle = filter.value.toLowerCase();
const options = needle
? allPlayerOptions.value.filter((o) => o.label.toLowerCase().includes(needle))
: allPlayerOptions.value;
if (playerId.value === CUSTOM_ID && nameOverride.value.trim()) {
return [{ value: CUSTOM_ID, label: nameOverride.value }, ...options];
}
return options;
});
const selectedPlayer = computed(() => playersStore.players[playerId.value]);
const getPlayerLabel = (id: string): string => {
if (id === CUSTOM_ID) return nameOverride.value;
return allPlayerOptions.value.find((o) => o.value === id)?.label ?? '';
};
const playerExistsByGamertag = (name: string): boolean => {
const normalized = normalizeName(name);
return Boolean(normalized)
&& Object.values(playersStore.players).some(
(p) => normalizeName(p.gamertag || '') === normalized,
);
};
// ---------------------------------------------------------------------------
// Derived state
// ---------------------------------------------------------------------------
const displayName = computed(
() => nameOverride.value || getPlayerLabel(playerId.value),
);
/** True when the typed name is new and can be saved as a new player. */
const canSave = computed(
() => Boolean(nameOverride.value.trim()) && !playerExistsByGamertag(nameOverride.value),
);
const teamChanged = computed(() => {
const player = selectedPlayer.value;
if (!player) return false;
return player.team !== teamOverride.value;
});
const countryChanged = computed(() => {
const player = selectedPlayer.value;
if (!player) return false;
return player.country !== countryOverride.value;
});
// Parentheses required: || and ?? cannot be mixed without them (TS5076)
const pendingGamertag = computed(
() => (nameOverride.value.trim() || selectedPlayer.value?.gamertag) ?? '',
);
const nameChanged = computed(() => {
const player = selectedPlayer.value;
if (!player) return false;
return player.gamertag !== pendingGamertag.value;
});
/** True when the name has changed and the new name doesn't collide. */
const canSaveNameChange = computed(
() => nameChanged.value && !playerExistsByGamertag(pendingGamertag.value),
);
/** Whether the save icon should appear in the player name field. */
const showsNameSave = computed(() => canSave.value || canSaveNameChange.value);
// ---------------------------------------------------------------------------
// Actions
// ---------------------------------------------------------------------------
const startCustomPlayer = () => {
const wasCustom = playerId.value === CUSTOM_ID;
playerId.value = CUSTOM_ID;
if (!wasCustom) {
teamOverride.value = '';
countryOverride.value = '';
}
};
const applyPlayerData = (id: string) => {
const player = playersStore.players[id];
if (!player) return;
teamOverride.value = player.team ?? '';
countryOverride.value = player.country ?? '';
};
const onFilter = (val: string, update: (fn: () => void) => void) => {
update(() => {
filter.value = val;
if (!focused.value) return;
// If the field is cleared while a custom player is selected, restore the name
if (!val.trim() && playerId.value === CUSTOM_ID) {
inputValue.value = nameOverride.value;
return;
}
inputValue.value = val;
nameOverride.value = val;
if (val.trim()) startCustomPlayer();
});
};
const onFocus = () => {
focused.value = true;
inputValue.value = displayName.value;
};
const onBlur = () => {
focused.value = false;
filter.value = '';
inputValue.value = displayName.value;
};
const onSelect = (id: string) => {
if (!id || !playersStore.players[id]) return;
focused.value = false;
nameOverride.value = '';
filter.value = '';
inputValue.value = getPlayerLabel(id);
applyPlayerData(id);
};
/** Save the typed name as a brand-new player entry. */
const savePlayer = () => {
const gamertag = nameOverride.value.trim();
if (!gamertag || playerExistsByGamertag(gamertag)) return;
const id = createPlayerId(gamertag, playersStore.players);
playersStore.upsertPlayer(id, {
gamertag,
name: '',
team: teamOverride.value,
country: countryOverride.value,
twitter: '',
});
playerId.value = id;
nameOverride.value = '';
inputValue.value = gamertag;
};
/** Persist a gamertag rename on an existing player. */
const saveNameChange = () => {
const player = selectedPlayer.value;
if (!player || !canSaveNameChange.value) return;
playersStore.upsertPlayer(playerId.value, { ...player, gamertag: pendingGamertag.value });
nameOverride.value = '';
};
const saveTeamChange = () => {
const player = selectedPlayer.value;
if (!player) return;
playersStore.upsertPlayer(playerId.value, { ...player, team: teamOverride.value });
};
const saveCountryChange = () => {
const player = selectedPlayer.value;
if (!player) return;
playersStore.upsertPlayer(playerId.value, { ...player, country: countryOverride.value });
};
/** Dispatches to savePlayer or saveNameChange depending on context. */
const onNameSave = () => {
if (canSave.value) {
savePlayer();
return;
}
saveNameChange();
};
// ---------------------------------------------------------------------------
// Watchers
// ---------------------------------------------------------------------------
// Sync team/country fields when player selection changes
watch(playerId, (id) => applyPlayerData(id), { immediate: true });
// Keep the search input display value in sync unless the field is focused
watchEffect(() => {
if (!focused.value) {
inputValue.value = displayName.value;
}
});
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
return {
// Store bindings (writable computed refs)
playerId,
nameOverride,
teamOverride,
countryOverride,
// UI state
inputValue,
countryInput,
filteredCountryOptions,
playerOptions,
// Derived state
displayName,
teamChanged,
countryChanged,
showsNameSave,
// Handlers
onFilter,
onFocus,
onBlur,
onSelect,
onNameSave,
saveTeamChange,
saveCountryChange,
onCountryFilter,
};
}
+39
View File
@@ -69,6 +69,7 @@ type Translations = {
bracketStage: string; bracketStage: string;
bracketSide: string; bracketSide: string;
bracketCustomProgress: string; bracketCustomProgress: string;
bracketPreview: string;
playersLabelTeam: string; playersLabelTeam: string;
playersLabelCountry: string; playersLabelCountry: string;
playersLabelActions: string; playersLabelActions: string;
@@ -84,6 +85,18 @@ type Translations = {
playersSearchPlaceholder: string; playersSearchPlaceholder: string;
playersImport: string; playersImport: string;
playersExport: string; playersExport: string;
commentaryTwitterMaxLength: string;
commentaryTwitterInvalidChars: string;
commentarySwap: string;
commentaryClear: string;
aboutChangelog : string;
aboutTechStackTitle : string;
settingsShortcutConflictWarning : string;
settingsShortcutStartRecording: string;
settingsShortcutStopRecording: string;
settingsShortcutResetSingle: string;
graphicsCopied : string;
graphicsOpenBrowser : string;
}; };
const STORAGE_KEY = 'scoreko-dev.language'; const STORAGE_KEY = 'scoreko-dev.language';
@@ -156,6 +169,7 @@ const messages: Record<Locale, Translations> = {
bracketStage: 'Stage', bracketStage: 'Stage',
bracketSide: 'Bracket side', bracketSide: 'Bracket side',
bracketCustomProgress: 'Custom progress', bracketCustomProgress: 'Custom progress',
bracketPreview: 'Preview',
playersLabelTeam: 'Team', playersLabelTeam: 'Team',
playersLabelCountry: 'Country', playersLabelCountry: 'Country',
playersLabelActions: 'Actions', playersLabelActions: 'Actions',
@@ -171,6 +185,18 @@ const messages: Record<Locale, Translations> = {
playersSearchPlaceholder: 'Search...', playersSearchPlaceholder: 'Search...',
playersImport: 'Import', playersImport: 'Import',
playersExport: 'Export', playersExport: 'Export',
commentaryTwitterMaxLength: 'Twitter character limit exceeded',
commentaryTwitterInvalidChars: 'Invalid characters in Twitter text',
commentarySwap: 'Swap commentators',
commentaryClear: 'Clear commentary',
aboutChangelog: 'Changelog',
aboutTechStackTitle: 'Tech stack',
settingsShortcutConflictWarning: 'This shortcut is already assigned to',
settingsShortcutStartRecording: 'Start recording shortcut',
settingsShortcutStopRecording: 'Stop recording shortcut',
settingsShortcutResetSingle: 'Reset single player score shortcut',
graphicsCopied: 'URL copied to clipboard',
graphicsOpenBrowser: 'Open in browser',
}, },
es: { es: {
menuDashboard: 'Panel', menuDashboard: 'Panel',
@@ -239,6 +265,7 @@ const messages: Record<Locale, Translations> = {
bracketStage: 'Etapa', bracketStage: 'Etapa',
bracketSide: 'Lado del bracket', bracketSide: 'Lado del bracket',
bracketCustomProgress: 'Progreso personalizado', bracketCustomProgress: 'Progreso personalizado',
bracketPreview: 'Vista previa',
playersLabelTeam: 'Equipo', playersLabelTeam: 'Equipo',
playersLabelCountry: 'País', playersLabelCountry: 'País',
playersLabelActions: 'Acciones', playersLabelActions: 'Acciones',
@@ -254,6 +281,18 @@ const messages: Record<Locale, Translations> = {
playersSearchPlaceholder: 'Buscar...', playersSearchPlaceholder: 'Buscar...',
playersImport: 'Importar', playersImport: 'Importar',
playersExport: 'Exportar', playersExport: 'Exportar',
commentaryTwitterMaxLength: 'Se excedió el límite de caracteres de Twitter',
commentaryTwitterInvalidChars: 'Caracteres inválidos en el texto de Twitter',
commentarySwap: 'Intercambiar comentaristas',
commentaryClear: 'Limpiar comentario',
aboutChangelog: 'Changelog',
aboutTechStackTitle: 'Tech stack',
settingsShortcutConflictWarning: 'This shortcut is already assigned to',
settingsShortcutStartRecording: 'Start recording shortcut',
settingsShortcutStopRecording: 'Stop recording shortcut',
settingsShortcutResetSingle: 'Reset single player score shortcut',
graphicsCopied: 'URL copiada al portapapeles',
graphicsOpenBrowser: 'Abrir en el navegador',
}, },
}; };
@@ -175,9 +175,15 @@ export const useShortcutSettingsStore = defineStore('shortcut-settings', () => {
persistSettings(shortcuts); persistSettings(shortcuts);
}; };
const resetShortcut = (action: ShortcutAction) => {
shortcuts[action] = defaultShortcuts[action];
persistSettings(shortcuts);
};
return { return {
shortcuts, shortcuts,
setShortcut, setShortcut,
resetShortcuts, resetShortcuts,
resetShortcut,
}; };
}); });
+209 -237
View File
@@ -1,280 +1,252 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, onMounted, ref } from 'vue';
import { t } from '../i18n'; import { t } from '../i18n';
defineOptions({ name: 'AboutView' }); defineOptions({ name: 'AboutView' });
useHead(() => ({ title: t('aboutTitle') })); useHead(() => ({ title: t('aboutTitle') }));
type ReleaseResponse = {
html_url: string;
name: string | null;
tag_name: string;
published_at: string;
};
const appName = 'Scoreko-dev'; const appName = 'Scoreko-dev';
const currentVersion = import.meta.env.PACKAGE_VERSION; const currentVersion = import.meta.env.PACKAGE_VERSION;
const updateRepoOwner = 'Pandipipas'; const repoUrl = 'https://github.com/Pandipipas/scoreko-dev';
const updateRepoName = 'scoreko'; const authorUrl = 'https://github.com/Pandipipas';
const checkingUpdates = ref(false);
const updateError = ref('');
const latestRelease = ref<ReleaseResponse | null>(null);
const collaborators = [ const collaborators = [
{ {
name: 'Pandipipas', name: 'Pandipipas',
role: 'Development and maintenance of Scoreko-dev', role: 'Development and maintenance of Scoreko-dev',
url: 'https://github.com/Pandipipas/scoreko-dev' url: authorUrl,
icon: 'code',
}, },
{ {
name: 'Dan Shields', name: 'Dan Shields',
role: 'nodecg-vue-composable helper', role: 'nodecg-vue-composable helper',
url: 'https://github.com/Dan-Shields/nodecg-vue-composable' url: 'https://github.com/Dan-Shields/nodecg-vue-composable',
icon: 'extension',
}, },
{ {
name: 'NodeCG', name: 'NodeCG',
role: 'Broadcast graphics framework', role: 'Broadcast graphics framework',
url: 'https://github.com/nodecg/nodecg' url: 'https://github.com/nodecg/nodecg',
} icon: 'layers',
},
]; ];
const releaseLabel = computed(() => { const techStack = [
if (!latestRelease.value) { { label: 'Vue 3', icon: 'hub' },
return ''; { label: 'Quasar', icon: 'style' },
} { label: 'TypeScript', icon: 'data_object' },
{ label: 'NodeCG', icon: 'layers' },
];
return latestRelease.value.name?.trim().length const currentYear = new Date().getFullYear();
? latestRelease.value.name
: latestRelease.value.tag_name;
});
const hasUpdate = computed(() => {
if (!latestRelease.value) {
return false;
}
return compareVersions(normalizeVersion(latestRelease.value.tag_name), normalizeVersion(currentVersion)) > 0;
});
const repoUrl = computed(() => `https://github.com/${updateRepoOwner}/${updateRepoName}`);
const releaseUrl = computed(() => latestRelease.value?.html_url ?? `${repoUrl.value}/releases`);
function normalizeVersion(version: string) {
return version.replace(/^v/i, '');
}
function compareVersions(a: string, b: string) {
const aParts = a.split('.').map((value) => Number(value));
const bParts = b.split('.').map((value) => Number(value));
const max = Math.max(aParts.length, bParts.length);
for (let index = 0; index < max; index += 1) {
const aPart = Number.isFinite(aParts[index]) ? aParts[index]! : 0;
const bPart = Number.isFinite(bParts[index]) ? bParts[index]! : 0;
if (aPart > bPart) {
return 1;
}
if (aPart < bPart) {
return -1;
}
}
return 0;
}
async function checkForUpdates() {
checkingUpdates.value = true;
updateError.value = '';
try {
const response = await fetch(
`https://api.github.com/repos/${encodeURIComponent(updateRepoOwner)}/${encodeURIComponent(updateRepoName)}/releases/latest`,
{
headers: {
Accept: 'application/vnd.github+json'
}
}
);
if (!response.ok) {
throw new Error(`${t('aboutGitHubStatusError')} ${response.status}.`);
}
latestRelease.value = await response.json() as ReleaseResponse;
} catch (error) {
latestRelease.value = null;
updateError.value = error instanceof Error ? error.message : t('aboutUnknownReleaseError');
} finally {
checkingUpdates.value = false;
}
}
onMounted(() => {
void checkForUpdates();
});
</script> </script>
<template> <template>
<QPage class="q-pa-lg"> <QPage class="q-pa-lg">
<div class="text-h4 q-mb-md"> <div class="q-mb-lg">
{{ t('aboutTitle') }} <div class="text-h5 text-weight-medium">
{{ t('aboutTitle') }}
</div>
</div> </div>
<div class="row q-col-gutter-lg"> <QCard
<div class="col-12 col-md-6"> flat
<QCard bordered
flat class="about-card"
bordered >
> <!-- App identity -->
<QCardSection class="row items-center q-col-gutter-md"> <QCardSection class="q-pa-lg">
<div class="col-auto"> <div class="row items-center q-gutter-md">
<QImg <QImg
src="../image.png" src="../image.png"
alt="Scoreko logo" alt="Scoreko logo"
width="72px" class="app-logo"
height="72px" fit="contain"
fit="contain" />
/> <div>
<div class="text-h6 text-weight-bold">
{{ appName }}
</div> </div>
<div class="col"> <div class="row items-center q-gutter-xs q-mt-xs">
<div class="text-h6"> <QBadge
{{ appName }} outline
</div>
<div class="text-caption text-grey-7">
{{ t('aboutVersion') }} {{ currentVersion }}
</div>
</div>
</QCardSection>
<QSeparator />
<QCardSection>
<p class="q-mb-sm">
{{ t('aboutDescription') }}
</p>
<div class="column q-gutter-sm">
<QBtn
href="https://github.com/nodecg/nodecg"
target="_blank"
rel="noopener noreferrer"
icon="open_in_new"
:label="t('aboutFrameworkNodeCG')"
color="primary" color="primary"
flat class="version-badge"
no-caps >
align="left" v{{ currentVersion }}
/> </QBadge>
</div> <QBtn
</QCardSection> :href="`${repoUrl}/releases`"
<QSeparator />
<QCardSection>
<div class="text-subtitle2 q-mb-sm">
{{ t('aboutCollaboratorsTitle') }}
</div>
<QList dense>
<QItem
v-for="person in collaborators"
:key="person.name"
tag="a"
:href="person.url"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> icon="history"
<QItemSection> :label="t('aboutChangelog')"
<QItemLabel>{{ person.name }}</QItemLabel> color="grey-6"
<QItemLabel caption> flat
{{ person.role }} dense
</QItemLabel> no-caps
</QItemSection> size="xs"
</QItem> />
</QList> </div>
</QCardSection> </div>
</QCard> </div>
</div> </QCardSection>
<div class="col-12 col-md-6"> <QSeparator />
<QCard
<!-- Description + framework -->
<QCardSection class="q-pa-lg">
<p class="text-body2 text-grey-7 q-mb-md">
{{ t('aboutDescription') }}
</p>
<QBtn
href="https://github.com/nodecg/nodecg"
target="_blank"
rel="noopener noreferrer"
icon="open_in_new"
:label="t('aboutFrameworkNodeCG')"
color="primary"
flat flat
bordered dense
no-caps
/>
</QCardSection>
<QSeparator />
<!-- Tech stack -->
<QCardSection class="q-pa-lg">
<div class="text-overline text-grey-6 q-mb-sm">
{{ t('aboutTechStackTitle') }}
</div>
<div class="row q-gutter-xs">
<QChip
v-for="tech in techStack"
:key="tech.label"
:icon="tech.icon"
:label="tech.label"
color="primary"
text-color="white"
size="sm"
dense
/>
</div>
</QCardSection>
<QSeparator />
<!-- Collaborators -->
<QCardSection class="q-pa-lg">
<div class="text-overline text-grey-6 q-mb-sm">
{{ t('aboutCollaboratorsTitle') }}
</div>
<QList
dense
class="collaborators-list"
> >
<QCardSection> <QItem
<div class="text-h6"> v-for="person in collaborators"
{{ t('aboutUpdateSystemTitle') }} :key="person.name"
</div> tag="a"
<div class="text-body2 text-grey-7 q-mt-xs"> :href="person.url"
{{ t('aboutUpdateSystemDescription') }} target="_blank"
</div> rel="noopener noreferrer"
</QCardSection> class="collaborator-item rounded-borders"
>
<QItemSection avatar>
<QIcon
:name="person.icon"
size="18px"
color="primary"
class="collaborator-icon"
/>
</QItemSection>
<QItemSection>
<QItemLabel class="text-weight-medium">
{{ person.name }}
</QItemLabel>
<QItemLabel
caption
class="text-grey-6"
>
{{ person.role }}
</QItemLabel>
</QItemSection>
<QItemSection side>
<QIcon
name="arrow_forward_ios"
size="12px"
color="grey-5"
/>
</QItemSection>
</QItem>
</QList>
</QCardSection>
<QSeparator /> <QSeparator />
<QCardSection class="q-gutter-md"> <!-- Footer -->
<QBtn <QCardSection class="q-pa-md">
:label="t('aboutCheckUpdates')" <div class="row items-center justify-between">
color="primary" <span class="text-caption text-grey-5">
icon="sync" © {{ currentYear }} Pandipipas · MIT License
:loading="checkingUpdates" </span>
no-caps <QBtn
@click="checkForUpdates" :href="repoUrl"
/> target="_blank"
rel="noopener noreferrer"
<QBanner icon="open_in_new"
v-if="latestRelease" label="GitHub"
rounded color="grey-6"
class="bg-grey-2" flat
> dense
<template #avatar> no-caps
<QIcon size="sm"
:name="hasUpdate ? 'system_update_alt' : 'check_circle'" />
:color="hasUpdate ? 'warning' : 'positive'" </div>
/> </QCardSection>
</template> </QCard>
<div class="text-subtitle2">
{{ t('aboutLatestRelease') }}: {{ releaseLabel }}
</div>
<div class="text-caption text-grey-7">
{{ t('aboutPublished') }}: {{ new Date(latestRelease.published_at).toLocaleString() }}
</div>
<div class="q-mt-sm">
{{ hasUpdate ? t('aboutUpdateAvailable') : t('aboutUpToDate') }}
</div>
<template #action>
<QBtn
flat
color="primary"
:label="t('aboutViewRelease')"
:href="releaseUrl"
target="_blank"
rel="noopener noreferrer"
no-caps
/>
</template>
</QBanner>
<QBanner
v-if="updateError"
rounded
class="bg-red-1 text-red-10"
>
{{ updateError }}
</QBanner>
<QBanner
rounded
class="bg-blue-1 text-blue-10"
>
{{ t('aboutElectronNote') }}
</QBanner>
</QCardSection>
</QCard>
</div>
</div>
</QPage> </QPage>
</template> </template>
<style scoped>
.about-card {
max-width: 520px;
}
.app-logo {
width: 56px;
height: 56px;
border-radius: 12px;
}
.version-badge {
font-size: 11px;
letter-spacing: 0.03em;
}
.collaborators-list {
margin: 0 -8px;
}
.collaborator-item {
border-radius: 8px;
transition: background 0.15s ease;
padding: 6px 8px;
text-decoration: none;
}
.collaborator-item:hover {
background: rgba(0, 0, 0, 0.04);
}
/* Dark mode hover fix */
.body--dark .collaborator-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.collaborator-icon {
opacity: 0.8;
}
</style>
@@ -11,9 +11,10 @@ useHead({ title: 'Dashboard' });
<template> <template>
<QPage class="q-pa-lg"> <QPage class="q-pa-lg">
<div class="dashboard-panels q-mt-lg"> <div class="dashboard-panels">
<div class="dashboard-row dashboard-row--scoreboard"> <div class="dashboard-row dashboard-row--scoreboard">
<QCard <QCard
flat
bordered bordered
class="dashboard-panel-card" class="dashboard-panel-card"
> >
@@ -25,6 +26,7 @@ useHead({ title: 'Dashboard' });
<div class="dashboard-row dashboard-row--bottom"> <div class="dashboard-row dashboard-row--bottom">
<QCard <QCard
flat
bordered bordered
class="dashboard-panel-card" class="dashboard-panel-card"
> >
@@ -33,6 +35,7 @@ useHead({ title: 'Dashboard' });
</QCardSection> </QCardSection>
</QCard> </QCard>
<QCard <QCard
flat
bordered bordered
class="dashboard-panel-card" class="dashboard-panel-card"
> >
@@ -53,20 +56,12 @@ useHead({ title: 'Dashboard' });
gap: 24px; gap: 24px;
} }
.dashboard-row {
width: 100%;
}
.dashboard-row--bottom { .dashboard-row--bottom {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 24px; gap: 24px;
} }
.dashboard-panel-card {
width: 100%;
}
.dashboard-panel-content { .dashboard-panel-content {
padding-bottom: 16px; padding-bottom: 16px;
} }
+42 -22
View File
@@ -1,13 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import bundlePackage from '../../../../package.json';
import { graphicsSettingsReplicant } from '../../../browser_shared/replicants'; import { graphicsSettingsReplicant } from '../../../browser_shared/replicants';
import { t } from '../i18n'; import { t } from '../i18n';
defineOptions({ name: 'GraphicsView' }); defineOptions({ name: 'GraphicsView' });
import bundlePackage from '../../../../package.json';
type GraphicConfig = { type GraphicConfig = {
name?: string; name?: string;
title?: string; title?: string;
@@ -130,19 +129,29 @@ const cards = computed<GraphicCard[]>(() => {
return result; return result;
}); });
const copyUrl = async (graphic: GraphicConfig) => { const copiedCardId = ref<string | null>(null);
const copyUrl = async (graphic: GraphicConfig, cardId: string) => {
const url = buildGraphicUrl(graphic); const url = buildGraphicUrl(graphic);
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
return; } else {
const input = document.createElement('input');
input.value = url;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
} }
const input = document.createElement('input'); copiedCardId.value = cardId;
input.value = url; setTimeout(() => {
document.body.appendChild(input); copiedCardId.value = null;
input.select(); }, 2000);
document.execCommand('copy'); };
document.body.removeChild(input);
const openUrl = (graphic: GraphicConfig) => {
window.open(buildGraphicUrl(graphic), '_blank');
}; };
const onDragStart = (event: DragEvent, graphic: GraphicConfig) => { const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
@@ -165,16 +174,18 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<template> <template>
<QPage class="q-pa-lg"> <QPage class="q-pa-lg">
<div class="text-h4 q-mb-md"> <div class="q-mb-lg">
{{ t('graphicsTitle') }} <div class="text-h5 text-weight-medium">
</div> {{ t('graphicsTitle') }}
<div class="text-body1 q-mb-lg"> </div>
{{ t('graphicsDescription') }} <div class="text-body2 text-grey-7 q-mt-xs">
{{ t('graphicsDescription') }}
</div>
</div> </div>
<div <div
v-if="cards.length === 0" v-if="cards.length === 0"
class="text-body2 text-grey-5" class="text-body2 text-grey-6"
> >
{{ t('graphicsNoConfigured') }} {{ t('graphicsNoConfigured') }}
</div> </div>
@@ -194,7 +205,7 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<div class="text-h6"> <div class="text-h6">
{{ card.label }} {{ card.label }}
</div> </div>
<div class="text-caption text-grey-5"> <div class="text-caption text-grey-4">
{{ card.graphic.file }} {{ card.graphic.file }}
</div> </div>
</div> </div>
@@ -224,18 +235,27 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<div class="row items-center q-gutter-sm"> <div class="row items-center q-gutter-sm">
<QBtn <QBtn
color="primary" :color="copiedCardId === card.id ? 'positive' : 'primary'"
icon="content_copy" :icon="copiedCardId === card.id ? 'check' : 'content_copy'"
:label="t('graphicsCopyUrl')" no-caps
@click="copyUrl(card.graphic)" :label="copiedCardId === card.id ? t('graphicsCopied') : t('graphicsCopyUrl')"
@click="copyUrl(card.graphic, card.id)"
/> />
<QBtn <QBtn
color="secondary" color="secondary"
icon="open_with" icon="open_with"
:label="t('graphicsDragObs')" no-caps
draggable="true" draggable="true"
:label="t('graphicsDragObs')"
@dragstart="onDragStart($event, card.graphic)" @dragstart="onDragStart($event, card.graphic)"
/> />
<QBtn
color="grey-7"
icon="open_in_new"
no-caps
:label="t('graphicsOpenBrowser')"
@click="openUrl(card.graphic)"
/>
</div> </div>
</QCardSection> </QCardSection>
</QCard> </QCard>
+126 -7
View File
@@ -1,8 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
defineOptions({ name: 'PlayersView' });
import type { QTableColumn } from 'quasar'; import type { QTableColumn } from 'quasar';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { getCountryLabel, getCountryOptions } from '../../../shared/countries'; import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
@@ -10,6 +7,8 @@ import type { Schemas } from '../../../types';
import { locale, t } from '../i18n'; import { locale, t } from '../i18n';
import { usePlayersStore } from '../stores/players'; import { usePlayersStore } from '../stores/players';
defineOptions({ name: 'PlayersView' });
useHead(() => ({ title: t('menuPlayers') })); useHead(() => ({ title: t('menuPlayers') }));
type PlayersMap = Schemas.Players; type PlayersMap = Schemas.Players;
@@ -525,6 +524,20 @@ const hasChallongeTokenConfigured = computed(() => Boolean(challongeToken.value.
const challongeConnectionLabel = computed(() => (hasValidatedChallongeToken.value ? t('playersConnected') : 'Token set')); const challongeConnectionLabel = computed(() => (hasValidatedChallongeToken.value ? t('playersConnected') : 'Token set'));
const playerSource = (id: string): 'startgg' | 'challonge' | null => {
if (id in temporaryStartGGPlayers.value) return 'startgg';
if (id in temporaryChallongePlayers.value) return 'challonge';
return null;
};
const playerExpiresAt = (id: string): number | null => {
const meta = temporaryStartGGPlayers.value[id] ?? temporaryChallongePlayers.value[id] ?? null;
return meta ? meta.expiresAt : null;
};
const formatExpiresAt = (ts: number): string =>
new Date(ts * 1000).toLocaleDateString(locale.value, { month: 'short', day: 'numeric', year: 'numeric' });
const filterChallongeTournaments = (value: string, update: (callback: () => void) => void) => { const filterChallongeTournaments = (value: string, update: (callback: () => void) => void) => {
update(() => { update(() => {
const needle = value.toLowerCase().trim(); const needle = value.toLowerCase().trim();
@@ -685,6 +698,22 @@ const openSelectedTournamentImportDialog = () => {
void openStartGGImportDialog(selectedTournamentOption.value); void openStartGGImportDialog(selectedTournamentOption.value);
}; };
const toggleAllStartGGPlayers = () => {
if (selectedStartGGPlayerIds.value.length === startGGPlayers.value.length) {
selectedStartGGPlayerIds.value = [];
} else {
selectedStartGGPlayerIds.value = startGGPlayers.value.map((p) => p.id);
}
};
const toggleAllChallongePlayers = () => {
if (selectedChallongePlayerIds.value.length === challongePlayers.value.length) {
selectedChallongePlayerIds.value = [];
} else {
selectedChallongePlayerIds.value = challongePlayers.value.map((p) => p.id);
}
};
const importSelectedStartGGPlayers = () => { const importSelectedStartGGPlayers = () => {
const selectedPlayers = startGGPlayers.value.filter((player) => const selectedPlayers = startGGPlayers.value.filter((player) =>
selectedStartGGPlayerIds.value.includes(player.id), selectedStartGGPlayerIds.value.includes(player.id),
@@ -810,13 +839,14 @@ onBeforeUnmount(() => {
<template> <template>
<QPage class="q-pa-lg players-page"> <QPage class="q-pa-lg players-page">
<div class="row items-center q-mb-md"> <div class="row items-center q-mb-md">
<div class="text-h4"> <div class="text-h5 text-weight-medium">
{{ t('menuPlayers') }} {{ t('menuPlayers') }}
</div> </div>
<QSpace /> <QSpace />
<QBtn <QBtn
color="primary" color="primary"
icon="add" icon="add"
no-caps
:label="t('playersNewPlayer')" :label="t('playersNewPlayer')"
class="q-ml-sm" class="q-ml-sm"
@click="openCreateDialog" @click="openCreateDialog"
@@ -837,10 +867,12 @@ onBeforeUnmount(() => {
<QIcon name="search" /> <QIcon name="search" />
</template> </template>
</QInput> </QInput>
<span class="text-caption text-grey-6">{{ rows.length }} players</span>
<QBtn <QBtn
color="secondary" color="secondary"
outline outline
icon="file_upload" icon="file_upload"
no-caps
:label="t('playersImport')" :label="t('playersImport')"
@click="triggerImport" @click="triggerImport"
/> />
@@ -848,6 +880,7 @@ onBeforeUnmount(() => {
color="secondary" color="secondary"
outline outline
icon="file_download" icon="file_download"
no-caps
:label="t('playersExport')" :label="t('playersExport')"
@click="exportPlayers" @click="exportPlayers"
/> />
@@ -871,6 +904,45 @@ onBeforeUnmount(() => {
:filter="filter" :filter="filter"
:rows-per-page-options="[10, 20, 50]" :rows-per-page-options="[10, 20, 50]"
> >
<template #body-cell-gamertag="{ row }">
<QTd>
<div class="row items-center q-gutter-x-sm">
<span class="text-weight-medium">{{ row.gamertag }}</span>
<QChip
v-if="playerSource(row.id) === 'startgg'"
dense
outline
color="blue-4"
class="q-ma-none"
style="font-size: 10px; height: 18px;"
>
start.gg
<QTooltip v-if="playerExpiresAt(row.id)">
Temporary · expires {{ formatExpiresAt(playerExpiresAt(row.id)!) }}
</QTooltip>
</QChip>
<QChip
v-else-if="playerSource(row.id) === 'challonge'"
dense
outline
color="orange-4"
class="q-ma-none"
style="font-size: 10px; height: 18px;"
>
Challonge
<QTooltip v-if="playerExpiresAt(row.id)">
Temporary · expires {{ formatExpiresAt(playerExpiresAt(row.id)!) }}
</QTooltip>
</QChip>
</div>
<div
v-if="row.name"
class="text-caption text-grey-6"
>
{{ row.name }}
</div>
</QTd>
</template>
<template #body-cell-actions="{ row }"> <template #body-cell-actions="{ row }">
<QTd align="right"> <QTd align="right">
<QBtn <QBtn
@@ -908,7 +980,7 @@ onBeforeUnmount(() => {
</svg> </svg>
<span>start.gg</span> <span>start.gg</span>
</div> </div>
<div class="text-caption q-mb-md"> <div class="text-caption text-grey-6 q-mb-md">
{{ t('playersStartggHelp') }} {{ t('playersStartggHelp') }}
</div> </div>
<div class="row q-col-gutter-sm items-center"> <div class="row q-col-gutter-sm items-center">
@@ -917,6 +989,7 @@ onBeforeUnmount(() => {
v-if="!hasStartGGTokenConfigured" v-if="!hasStartGGTokenConfigured"
color="primary" color="primary"
icon="login" icon="login"
no-caps
:label="t('playersConnectStartgg')" :label="t('playersConnectStartgg')"
:loading="oauthLoading" :loading="oauthLoading"
@click="connectWithStartGGOAuth" @click="connectWithStartGGOAuth"
@@ -926,6 +999,7 @@ onBeforeUnmount(() => {
outline outline
color="positive" color="positive"
icon="check_circle" icon="check_circle"
no-caps
:label="t('playersConnected')" :label="t('playersConnected')"
class="startgg-connected-btn" class="startgg-connected-btn"
@click="openManualTokenDialog" @click="openManualTokenDialog"
@@ -936,6 +1010,7 @@ onBeforeUnmount(() => {
outline outline
color="white" color="white"
icon="vpn_key" icon="vpn_key"
no-caps
:label="t('playersUsePersonalApi')" :label="t('playersUsePersonalApi')"
@click="openManualTokenDialog" @click="openManualTokenDialog"
/> />
@@ -1020,7 +1095,7 @@ onBeforeUnmount(() => {
> >
<span>Challonge</span> <span>Challonge</span>
</div> </div>
<div class="text-caption q-mb-md"> <div class="text-caption text-grey-6 q-mb-md">
{{ t('playersChallongeHelp') }} {{ t('playersChallongeHelp') }}
</div> </div>
<div class="row q-col-gutter-sm items-center"> <div class="row q-col-gutter-sm items-center">
@@ -1029,6 +1104,7 @@ onBeforeUnmount(() => {
v-if="!hasChallongeTokenConfigured" v-if="!hasChallongeTokenConfigured"
color="primary" color="primary"
icon="login" icon="login"
no-caps
:label="t('playersConnectChallonge')" :label="t('playersConnectChallonge')"
:loading="challongeOauthLoading" :loading="challongeOauthLoading"
@click="connectWithChallongeOAuth" @click="connectWithChallongeOAuth"
@@ -1038,6 +1114,7 @@ onBeforeUnmount(() => {
outline outline
:color="hasValidatedChallongeToken ? 'positive' : 'warning'" :color="hasValidatedChallongeToken ? 'positive' : 'warning'"
icon="check_circle" icon="check_circle"
no-caps
:label="challongeConnectionLabel" :label="challongeConnectionLabel"
@click="openChallongeManualTokenDialog" @click="openChallongeManualTokenDialog"
/> />
@@ -1047,6 +1124,7 @@ onBeforeUnmount(() => {
outline outline
color="white" color="white"
icon="vpn_key" icon="vpn_key"
no-caps
:label="t('playersUsePersonalApi')" :label="t('playersUsePersonalApi')"
@click="openChallongeManualTokenDialog" @click="openChallongeManualTokenDialog"
/> />
@@ -1121,7 +1199,6 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<QDialog v-model="isManualTokenDialogOpen"> <QDialog v-model="isManualTokenDialogOpen">
<QCard class="players-dialog"> <QCard class="players-dialog">
<QCardSection> <QCardSection>
@@ -1153,17 +1230,20 @@ onBeforeUnmount(() => {
<QCardActions align="right"> <QCardActions align="right">
<QBtn <QBtn
flat flat
no-caps
label="Cancel" label="Cancel"
color="secondary" color="secondary"
@click="isManualTokenDialogOpen = false" @click="isManualTokenDialogOpen = false"
/> />
<QBtn <QBtn
flat flat
no-caps
color="negative" color="negative"
label="Delete token" label="Delete token"
@click="manualTokenDraft = ''; saveManualToken()" @click="manualTokenDraft = ''; saveManualToken()"
/> />
<QBtn <QBtn
no-caps
color="primary" color="primary"
label="Save token" label="Save token"
@click="saveManualToken" @click="saveManualToken"
@@ -1189,6 +1269,20 @@ onBeforeUnmount(() => {
<span>Loading participants...</span> <span>Loading participants...</span>
</div> </div>
<div v-else> <div v-else>
<div class="row q-gutter-sm q-mb-sm">
<QBtn
flat
dense
no-caps
size="sm"
color="primary"
:label="selectedStartGGPlayerIds.length === startGGPlayers.length ? 'Deselect all' : 'Select all'"
@click="toggleAllStartGGPlayers"
/>
<span class="text-caption text-grey-6 self-center">
{{ selectedStartGGPlayerIds.length }} / {{ startGGPlayers.length }} selected
</span>
</div>
<QOptionGroup <QOptionGroup
v-model="selectedStartGGPlayerIds" v-model="selectedStartGGPlayerIds"
type="checkbox" type="checkbox"
@@ -1203,11 +1297,13 @@ onBeforeUnmount(() => {
<QCardActions align="right"> <QCardActions align="right">
<QBtn <QBtn
flat flat
no-caps
label="Cancel" label="Cancel"
color="secondary" color="secondary"
@click="isImportDialogOpen = false" @click="isImportDialogOpen = false"
/> />
<QBtn <QBtn
no-caps
color="primary" color="primary"
label="Import selected" label="Import selected"
:disable="!selectedStartGGPlayerIds.length" :disable="!selectedStartGGPlayerIds.length"
@@ -1234,6 +1330,20 @@ onBeforeUnmount(() => {
<span>Loading participants...</span> <span>Loading participants...</span>
</div> </div>
<div v-else> <div v-else>
<div class="row q-gutter-sm q-mb-sm">
<QBtn
flat
dense
no-caps
size="sm"
color="primary"
:label="selectedChallongePlayerIds.length === challongePlayers.length ? 'Deselect all' : 'Select all'"
@click="toggleAllChallongePlayers"
/>
<span class="text-caption text-grey-6 self-center">
{{ selectedChallongePlayerIds.length }} / {{ challongePlayers.length }} selected
</span>
</div>
<QOptionGroup <QOptionGroup
v-model="selectedChallongePlayerIds" v-model="selectedChallongePlayerIds"
type="checkbox" type="checkbox"
@@ -1248,11 +1358,13 @@ onBeforeUnmount(() => {
<QCardActions align="right"> <QCardActions align="right">
<QBtn <QBtn
flat flat
no-caps
label="Cancel" label="Cancel"
color="secondary" color="secondary"
@click="challongeImportDialogOpen = false" @click="challongeImportDialogOpen = false"
/> />
<QBtn <QBtn
no-caps
color="primary" color="primary"
label="Import selected" label="Import selected"
:disable="!selectedChallongePlayerIds.length" :disable="!selectedChallongePlayerIds.length"
@@ -1286,17 +1398,20 @@ onBeforeUnmount(() => {
<QCardActions align="right"> <QCardActions align="right">
<QBtn <QBtn
flat flat
no-caps
label="Cancel" label="Cancel"
color="secondary" color="secondary"
@click="isChallongeManualTokenDialogOpen = false" @click="isChallongeManualTokenDialogOpen = false"
/> />
<QBtn <QBtn
flat flat
no-caps
color="negative" color="negative"
label="Delete token" label="Delete token"
@click="challongeManualTokenDraft = ''; saveChallongeManualToken()" @click="challongeManualTokenDraft = ''; saveChallongeManualToken()"
/> />
<QBtn <QBtn
no-caps
color="primary" color="primary"
label="Save token" label="Save token"
@click="saveChallongeManualToken" @click="saveChallongeManualToken"
@@ -1323,6 +1438,8 @@ onBeforeUnmount(() => {
dense dense
class="players-underlined-field" class="players-underlined-field"
autofocus autofocus
:rules="[(v) => !!v?.trim() || 'Gamertag is required']"
lazy-rules
/> />
</div> </div>
<div class="col-12"> <div class="col-12">
@@ -1376,11 +1493,13 @@ onBeforeUnmount(() => {
<QCardActions align="right"> <QCardActions align="right">
<QBtn <QBtn
flat flat
no-caps
label="Cancel" label="Cancel"
color="secondary" color="secondary"
@click="isDialogOpen = false" @click="isDialogOpen = false"
/> />
<QBtn <QBtn
no-caps
color="primary" color="primary"
label="Save" label="Save"
@click="savePlayer" @click="savePlayer"
+117 -30
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import type { Locale } from '../i18n'; import type { Locale } from '../i18n';
import { locale, setLocale, t } from '../i18n'; import { locale, setLocale, t } from '../i18n';
import { import {
@@ -11,6 +11,8 @@ import {
defineOptions({ name: 'SettingsView' }); defineOptions({ name: 'SettingsView' });
useHead(() => ({ title: t('settingsTitle') }));
const languageOptions = computed(() => [ const languageOptions = computed(() => [
{ label: t('languageSpanish'), value: 'es' as const }, { label: t('languageSpanish'), value: 'es' as const },
{ label: t('languageEnglish'), value: 'en' as const }, { label: t('languageEnglish'), value: 'en' as const },
@@ -26,6 +28,9 @@ const selectedLanguage = computed<Locale>({
const shortcutSettingsStore = useShortcutSettingsStore(); const shortcutSettingsStore = useShortcutSettingsStore();
const recordingAction = ref<ShortcutAction | null>(null); const recordingAction = ref<ShortcutAction | null>(null);
// Ref para detectar clicks fuera del contenedor de atajos
const shortcutsContainerRef = ref<HTMLElement | null>(null);
const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: string }[]>(() => [ const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: string }[]>(() => [
{ action: 'leftIncrement', label: t('settingsShortcutLeftIncrementLabel'), hint: t('settingsShortcutLeftIncrementHint') }, { action: 'leftIncrement', label: t('settingsShortcutLeftIncrementLabel'), hint: t('settingsShortcutLeftIncrementHint') },
{ action: 'leftDecrement', label: t('settingsShortcutLeftDecrementLabel'), hint: t('settingsShortcutLeftDecrementHint') }, { action: 'leftDecrement', label: t('settingsShortcutLeftDecrementLabel'), hint: t('settingsShortcutLeftDecrementHint') },
@@ -33,6 +38,21 @@ const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: s
{ action: 'rightDecrement', label: t('settingsShortcutRightDecrementLabel'), hint: t('settingsShortcutRightDecrementHint') }, { action: 'rightDecrement', label: t('settingsShortcutRightDecrementLabel'), hint: t('settingsShortcutRightDecrementHint') },
]); ]);
// Detecta atajos duplicados entre acciones
const conflictingActions = computed(() => {
const seen = new Map<string, ShortcutAction>();
const conflicts = new Set<ShortcutAction>();
for (const [action, shortcut] of Object.entries(shortcutSettingsStore.shortcuts) as [ShortcutAction, string][]) {
if (seen.has(shortcut)) {
conflicts.add(action);
conflicts.add(seen.get(shortcut)!);
} else {
seen.set(shortcut, action);
}
}
return conflicts;
});
const stopRecording = () => { const stopRecording = () => {
recordingAction.value = null; recordingAction.value = null;
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
@@ -41,20 +61,34 @@ const stopRecording = () => {
}; };
const onRecordKeydown = (event: KeyboardEvent) => { const onRecordKeydown = (event: KeyboardEvent) => {
if (!recordingAction.value) { if (!recordingAction.value) return;
// Escape cancela la grabación sin asignar ningún atajo
if (event.key === 'Escape') {
event.preventDefault();
stopRecording();
return; return;
} }
const shortcut = eventToShortcut(event); const shortcut = eventToShortcut(event);
if (!shortcut) { if (!shortcut) return;
return;
}
event.preventDefault(); event.preventDefault();
shortcutSettingsStore.setShortcut(recordingAction.value, shortcut); shortcutSettingsStore.setShortcut(recordingAction.value, shortcut);
stopRecording(); stopRecording();
}; };
// Click fuera del área de atajos también cancela la grabación
const onDocumentMousedown = (event: MouseEvent) => {
if (
recordingAction.value &&
shortcutsContainerRef.value &&
!shortcutsContainerRef.value.contains(event.target as Node)
) {
stopRecording();
}
};
const startRecording = (action: ShortcutAction) => { const startRecording = (action: ShortcutAction) => {
if (recordingAction.value === action) { if (recordingAction.value === action) {
stopRecording(); stopRecording();
@@ -69,55 +103,61 @@ const startRecording = (action: ShortcutAction) => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('keydown', onRecordKeydown); window.addEventListener('keydown', onRecordKeydown);
document.addEventListener('mousedown', onDocumentMousedown);
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onRecordKeydown); window.removeEventListener('keydown', onRecordKeydown);
document.removeEventListener('mousedown', onDocumentMousedown);
} }
stopRecording(); stopRecording();
}); });
useHead(() => ({ title: t('settingsTitle') }));
</script> </script>
<template> <template>
<QPage class="q-pa-lg"> <QPage class="q-pa-lg">
<div class="text-h4 q-mb-md"> <div class="q-mb-lg">
{{ t('settingsTitle') }} <div class="text-h5 text-weight-medium">
</div> {{ t('settingsTitle') }}
<div class="text-body1 q-mb-lg"> </div>
{{ t('settingsDescription') }} <div class="text-body2 text-grey-7 q-mt-xs">
{{ t('settingsDescription') }}
</div>
</div> </div>
<QCard <QCard
flat flat
bordered bordered
class="q-pa-md settings-card" class="settings-card"
> >
<QCardSection class="q-pa-none q-mb-lg"> <!-- Language -->
<div class="text-subtitle1 q-mb-sm"> <QCardSection class="q-pa-lg">
{{ t('settingsLanguageLabel') }} <!--
</div> Label movido al propio QSelect (más idiomático en Quasar con outlined).
Se elimina el text-overline redundante de encima.
-->
<QSelect <QSelect
v-model="selectedLanguage" v-model="selectedLanguage"
emit-value emit-value
map-options map-options
:label="t('settingsLanguageLabel')"
:options="languageOptions" :options="languageOptions"
:label="t('settingsLanguageLabel')"
outlined
dense
/> />
<div class="text-caption text-grey-5 q-mt-sm"> <div class="text-caption text-grey-6 q-mt-sm">
{{ t('settingsLanguageHint') }} {{ t('settingsLanguageHint') }}
</div> </div>
</QCardSection> </QCardSection>
<QSeparator class="q-mb-lg" /> <QSeparator />
<QCardSection class="q-pa-none"> <!-- Shortcuts -->
<div class="row items-center justify-between q-mb-sm"> <QCardSection class="q-pa-lg">
<div class="text-subtitle1"> <div class="row items-center justify-between q-mb-xs">
<div class="text-overline text-grey-6">
{{ t('settingsShortcutTitle') }} {{ t('settingsShortcutTitle') }}
</div> </div>
<QBtn <QBtn
@@ -133,30 +173,77 @@ useHead(() => ({ title: t('settingsTitle') }));
</QBtn> </QBtn>
</div> </div>
<div class="text-caption text-grey-5 q-mb-md"> <div class="text-caption text-grey-6 q-mb-lg">
{{ t('settingsShortcutDescription') }} {{ t('settingsShortcutDescription') }}
</div> </div>
<div class="column q-gutter-md"> <!-- Aviso de conflicto: se muestra si dos acciones comparten el mismo atajo -->
<QBanner
v-if="conflictingActions.size > 0"
class="bg-warning text-white q-mb-md"
rounded
dense
>
<template #avatar>
<QIcon name="warning" color="white" />
</template>
{{ t('settingsShortcutConflictWarning') }}
</QBanner>
<!--
ref="shortcutsContainerRef" permite detectar clicks fuera
de esta área para cancelar la grabación automáticamente.
-->
<div
ref="shortcutsContainerRef"
class="column q-gutter-md"
>
<QInput <QInput
v-for="field in shortcutFields" v-for="field in shortcutFields"
:key="field.action" :key="field.action"
:model-value="shortcutSettingsStore.shortcuts[field.action]" :model-value="shortcutSettingsStore.shortcuts[field.action]"
:hint="recordingAction === field.action ? t('settingsShortcutRecordingHint') : field.hint"
:color="
recordingAction === field.action
? 'negative'
: conflictingActions.has(field.action)
? 'warning'
: 'primary'
"
readonly readonly
outlined
dense
bottom-slots
:label="field.label" :label="field.label"
> >
<template #append> <template #append>
<!-- Botón grabar / detener -->
<QBtn <QBtn
flat flat
round round
dense dense
:icon="recordingAction === field.action ? 'stop_circle' : 'keyboard'" :icon="recordingAction === field.action ? 'stop_circle' : 'keyboard'"
:color="recordingAction === field.action ? 'negative' : 'primary'" :color="recordingAction === field.action ? 'negative' : 'primary'"
:aria-label="
recordingAction === field.action
? t('settingsShortcutStopRecording')
: t('settingsShortcutStartRecording')
"
@click="startRecording(field.action)" @click="startRecording(field.action)"
/> />
</template>
<template #hint> <!-- Botón reset individual por atajo -->
{{ recordingAction === field.action ? t('settingsShortcutRecordingHint') : field.hint }} <QBtn
flat
round
dense
icon="restart_alt"
color="grey-5"
:aria-label="t('settingsShortcutResetSingle')"
@click="shortcutSettingsStore.resetShortcut(field.action)"
>
<QTooltip>{{ t('settingsShortcutResetSingle') }}</QTooltip>
</QBtn>
</template> </template>
</QInput> </QInput>
</div> </div>
@@ -167,6 +254,6 @@ useHead(() => ({ title: t('settingsTitle') }));
<style scoped> <style scoped>
.settings-card { .settings-card {
max-width: 720px; max-width: 600px;
} }
</style> </style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

+6 -1
View File
@@ -1,9 +1,14 @@
{ {
/* Settings here partially mimick those included in a generated Vue project (npm create vue). */ /* Settings here partially mimick those included in a generated Vue project (pnpm create vue). */
/* Settings used for anything browser related (dashboard and graphics). */ /* Settings used for anything browser related (dashboard and graphics). */
"extends": "@vue/tsconfig/tsconfig.dom.json", "extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"typeRoots": [ "typeRoots": [
"./node_modules/@types" "./node_modules/@types"
], ],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
/* Settings used for anything extension related. */ /* Settings used for anything extension related. */
"extends": "@tsconfig/node22/tsconfig.json", "extends": "@tsconfig/node24/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"typeRoots": [ "typeRoots": [
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
/* Settings here mimick those included in a generated Vue project (npm create vue). */ /* Settings here mimick those included in a generated Vue project (pnpm create vue). */
/* They are only used for the vite.config.ts file. */ /* They are only used for the vite.config.ts file. */
"extends": "@tsconfig/node22/tsconfig.json", "extends": "@tsconfig/node24/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": ".", "baseUrl": ".",
"typeRoots": [ "typeRoots": [