mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
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.
This commit is contained in:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user