Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37f9ffb786 | |||
| d76a51c321 | |||
| 2ee111a0ca | |||
| 7b302e4c21 | |||
| 6dbf648323 | |||
| a4dc89575d | |||
| 4da00508d3 | |||
| 21d885f6e6 | |||
| d8d3c7f03c | |||
| 7314e73a1b | |||
| 61e565d358 | |||
| 8040b4fe51 | |||
| 91a8ce730c | |||
| 7a5c1ec637 |
@@ -23,17 +23,22 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.0.8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
run: pnpm run lint
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
run: pnpm run build
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
@@ -48,8 +48,11 @@ web_modules/
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
# Optional pnpm cache directory
|
||||
.pnpm-store
|
||||
.corepack/
|
||||
.npm-cache/
|
||||
.node-gyp/
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
@@ -66,7 +69,7 @@ web_modules/
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
# Output of 'pnpm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
@@ -134,3 +137,8 @@ dist
|
||||
/extension/
|
||||
/graphics/
|
||||
/shared/dist/
|
||||
|
||||
# Local runtime database
|
||||
/db/
|
||||
*.sqlite3
|
||||
/scoreko-electron-dev/
|
||||
|
||||
@@ -14,12 +14,12 @@ NodeCG bundle for producing fighting game overlays.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `npm run autofix`: automatically fixes lint errors.
|
||||
- `npm run build`: builds dashboard/graphics and extension.
|
||||
- `npm run lint`: validates project linting.
|
||||
- `npm run schema-types`: generates types from schemas.
|
||||
- `npm run start`: starts NodeCG.
|
||||
- `npm run watch`: development mode with watch.
|
||||
- `pnpm run autofix`: automatically fixes lint errors.
|
||||
- `pnpm run build`: builds dashboard/graphics and extension.
|
||||
- `pnpm run lint`: validates project linting.
|
||||
- `pnpm run schema-types`: generates types from schemas.
|
||||
- `pnpm run start`: starts NodeCG.
|
||||
- `pnpm run watch`: development mode with watch.
|
||||
|
||||
## Version
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Pandipipas",
|
||||
"packageManager": "pnpm@11.0.8",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"autofix": "eslint --fix",
|
||||
@@ -26,8 +27,8 @@
|
||||
"@eslint/js": "^9.39.0",
|
||||
"@quasar/extras": "^1.17.0",
|
||||
"@quasar/vite-plugin": "^1.10.0",
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/node": "^22.18.13",
|
||||
"@tsconfig/node24": "^24.0.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@unhead/vue": "^2.0.19",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
|
||||
@@ -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">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { t } from '../i18n';
|
||||
import { useScoreboardStore } from '../stores/scoreboard';
|
||||
|
||||
const scoreboardStore = useScoreboardStore();
|
||||
|
||||
let customDeactivateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const stageOptions = [
|
||||
'pools',
|
||||
'top 128',
|
||||
@@ -25,7 +27,7 @@ const stageOptions = [
|
||||
const bracketSideOptions = [
|
||||
{ label: 'None', value: '' },
|
||||
{ label: 'Winners', value: 'Winners' },
|
||||
{ label: 'Loosers', value: 'Loosers' },
|
||||
{ label: 'Losers', value: 'Losers' },
|
||||
];
|
||||
|
||||
const stage = ref(stageOptions[0]);
|
||||
@@ -34,6 +36,14 @@ const customActive = ref(false);
|
||||
const customText = ref('');
|
||||
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 round = scoreboardStore.scoreboard.round.trim();
|
||||
if (!round) {
|
||||
@@ -90,8 +100,14 @@ watch(customActive, (value) => {
|
||||
});
|
||||
|
||||
watch(customText, (value) => {
|
||||
if (customDeactivateTimer) {
|
||||
clearTimeout(customDeactivateTimer);
|
||||
}
|
||||
if (!value.trim()) {
|
||||
customDeactivateTimer = setTimeout(() => {
|
||||
customActive.value = false;
|
||||
customDeactivateTimer = null;
|
||||
}, 600);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -112,6 +128,7 @@ onMounted(() => {
|
||||
v-model="stage"
|
||||
:label="t('bracketStage')"
|
||||
:options="stageOptions"
|
||||
:disable="customActive"
|
||||
dense
|
||||
class="bracket-panel__field"
|
||||
/>
|
||||
@@ -119,6 +136,7 @@ onMounted(() => {
|
||||
v-model="bracketSide"
|
||||
:label="t('bracketSide')"
|
||||
:options="bracketSideOptions"
|
||||
:disable="customActive"
|
||||
dense
|
||||
emit-value
|
||||
map-options
|
||||
@@ -129,6 +147,7 @@ onMounted(() => {
|
||||
v-model="customText"
|
||||
:label="t('bracketCustomProgress')"
|
||||
dense
|
||||
clearable
|
||||
class="bracket-panel-custom-input bracket-panel__field"
|
||||
/>
|
||||
<QToggle
|
||||
@@ -138,6 +157,17 @@ onMounted(() => {
|
||||
class="bracket-panel-custom-toggle"
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
@@ -183,4 +213,36 @@ onMounted(() => {
|
||||
.bracket-panel__field :deep(.q-field__label) {
|
||||
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>
|
||||
@@ -1,8 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { t } from '../i18n';
|
||||
import { useCommentaryStore } from '../stores/commentary';
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -14,7 +71,9 @@ const commentaryStore = useCommentaryStore();
|
||||
</div>
|
||||
|
||||
<div class="commentary-panel__layout">
|
||||
<!-- Commentator 1 -->
|
||||
<div class="commentary-panel__commentator">
|
||||
|
||||
<QInput
|
||||
v-model="commentaryStore.leftCommentator"
|
||||
:label="t('commentaryCommentator1')"
|
||||
@@ -27,13 +86,27 @@ const commentaryStore = useCommentaryStore();
|
||||
</QInput>
|
||||
|
||||
<QInput
|
||||
v-model="commentaryStore.leftCommentatorTwitter"
|
||||
:model-value="commentaryStore.leftCommentatorTwitter"
|
||||
:label="t('commentaryTwitterText')"
|
||||
:rules="twitterRules"
|
||||
:maxlength="TWITTER_MAX_LENGTH"
|
||||
dense
|
||||
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>
|
||||
|
||||
<!-- Center controls -->
|
||||
<div class="commentary-panel__center-controls">
|
||||
<QBtn
|
||||
flat
|
||||
dense
|
||||
@@ -41,9 +114,30 @@ const commentaryStore = useCommentaryStore();
|
||||
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">
|
||||
|
||||
<QInput
|
||||
v-model="commentaryStore.rightCommentator"
|
||||
:label="t('commentaryCommentator2')"
|
||||
@@ -56,11 +150,23 @@ const commentaryStore = useCommentaryStore();
|
||||
</QInput>
|
||||
|
||||
<QInput
|
||||
v-model="commentaryStore.rightCommentatorTwitter"
|
||||
:model-value="commentaryStore.rightCommentatorTwitter"
|
||||
:label="t('commentaryTwitterText')"
|
||||
:rules="twitterRules"
|
||||
:maxlength="TWITTER_MAX_LENGTH"
|
||||
dense
|
||||
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>
|
||||
@@ -89,6 +195,35 @@ const commentaryStore = useCommentaryStore();
|
||||
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) {
|
||||
min-height: 28px;
|
||||
padding: 0;
|
||||
@@ -112,14 +247,27 @@ const commentaryStore = useCommentaryStore();
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.commentary-panel__swap-btn {
|
||||
color: #fff;
|
||||
opacity: 0.85;
|
||||
/* Handle preview */
|
||||
.commentary-panel__handle-preview {
|
||||
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 {
|
||||
opacity: 1;
|
||||
text-shadow: 0 0 10px rgba(255, 255, 255, 0.45);
|
||||
.commentary-panel__preview-enter-active,
|
||||
.commentary-panel__preview-leave-active {
|
||||
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) {
|
||||
@@ -127,8 +275,9 @@ const commentaryStore = useCommentaryStore();
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.commentary-panel__swap-btn {
|
||||
.commentary-panel__center-controls {
|
||||
justify-self: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -69,6 +69,7 @@ type Translations = {
|
||||
bracketStage: string;
|
||||
bracketSide: string;
|
||||
bracketCustomProgress: string;
|
||||
bracketPreview: string;
|
||||
playersLabelTeam: string;
|
||||
playersLabelCountry: string;
|
||||
playersLabelActions: string;
|
||||
@@ -84,6 +85,18 @@ type Translations = {
|
||||
playersSearchPlaceholder: string;
|
||||
playersImport: 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';
|
||||
@@ -156,6 +169,7 @@ const messages: Record<Locale, Translations> = {
|
||||
bracketStage: 'Stage',
|
||||
bracketSide: 'Bracket side',
|
||||
bracketCustomProgress: 'Custom progress',
|
||||
bracketPreview: 'Preview',
|
||||
playersLabelTeam: 'Team',
|
||||
playersLabelCountry: 'Country',
|
||||
playersLabelActions: 'Actions',
|
||||
@@ -171,6 +185,18 @@ const messages: Record<Locale, Translations> = {
|
||||
playersSearchPlaceholder: 'Search...',
|
||||
playersImport: 'Import',
|
||||
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: {
|
||||
menuDashboard: 'Panel',
|
||||
@@ -239,6 +265,7 @@ const messages: Record<Locale, Translations> = {
|
||||
bracketStage: 'Etapa',
|
||||
bracketSide: 'Lado del bracket',
|
||||
bracketCustomProgress: 'Progreso personalizado',
|
||||
bracketPreview: 'Vista previa',
|
||||
playersLabelTeam: 'Equipo',
|
||||
playersLabelCountry: 'País',
|
||||
playersLabelActions: 'Acciones',
|
||||
@@ -254,6 +281,18 @@ const messages: Record<Locale, Translations> = {
|
||||
playersSearchPlaceholder: 'Buscar...',
|
||||
playersImport: 'Importar',
|
||||
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);
|
||||
};
|
||||
|
||||
const resetShortcut = (action: ShortcutAction) => {
|
||||
shortcuts[action] = defaultShortcuts[action];
|
||||
persistSettings(shortcuts);
|
||||
};
|
||||
|
||||
return {
|
||||
shortcuts,
|
||||
setShortcut,
|
||||
resetShortcuts,
|
||||
resetShortcut,
|
||||
};
|
||||
});
|
||||
@@ -1,163 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { t } from '../i18n';
|
||||
|
||||
defineOptions({ name: 'AboutView' });
|
||||
|
||||
useHead(() => ({ title: t('aboutTitle') }));
|
||||
|
||||
type ReleaseResponse = {
|
||||
html_url: string;
|
||||
name: string | null;
|
||||
tag_name: string;
|
||||
published_at: string;
|
||||
};
|
||||
|
||||
const appName = 'Scoreko-dev';
|
||||
const currentVersion = import.meta.env.PACKAGE_VERSION;
|
||||
const updateRepoOwner = 'Pandipipas';
|
||||
const updateRepoName = 'scoreko';
|
||||
|
||||
const checkingUpdates = ref(false);
|
||||
const updateError = ref('');
|
||||
const latestRelease = ref<ReleaseResponse | null>(null);
|
||||
const repoUrl = 'https://github.com/Pandipipas/scoreko-dev';
|
||||
const authorUrl = 'https://github.com/Pandipipas';
|
||||
|
||||
const collaborators = [
|
||||
{
|
||||
name: 'Pandipipas',
|
||||
role: 'Development and maintenance of Scoreko-dev',
|
||||
url: 'https://github.com/Pandipipas/scoreko-dev'
|
||||
url: authorUrl,
|
||||
icon: 'code',
|
||||
},
|
||||
{
|
||||
name: 'Dan Shields',
|
||||
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',
|
||||
role: 'Broadcast graphics framework',
|
||||
url: 'https://github.com/nodecg/nodecg'
|
||||
}
|
||||
url: 'https://github.com/nodecg/nodecg',
|
||||
icon: 'layers',
|
||||
},
|
||||
];
|
||||
|
||||
const releaseLabel = computed(() => {
|
||||
if (!latestRelease.value) {
|
||||
return '';
|
||||
}
|
||||
const techStack = [
|
||||
{ label: 'Vue 3', icon: 'hub' },
|
||||
{ label: 'Quasar', icon: 'style' },
|
||||
{ label: 'TypeScript', icon: 'data_object' },
|
||||
{ label: 'NodeCG', icon: 'layers' },
|
||||
];
|
||||
|
||||
return latestRelease.value.name?.trim().length
|
||||
? 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();
|
||||
});
|
||||
const currentYear = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<QPage class="q-pa-lg">
|
||||
<div class="text-h4 q-mb-md">
|
||||
<div class="q-mb-lg">
|
||||
<div class="text-h5 text-weight-medium">
|
||||
{{ t('aboutTitle') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-lg">
|
||||
<div class="col-12 col-md-6">
|
||||
<QCard
|
||||
flat
|
||||
bordered
|
||||
class="about-card"
|
||||
>
|
||||
<QCardSection class="row items-center q-col-gutter-md">
|
||||
<div class="col-auto">
|
||||
<!-- App identity -->
|
||||
<QCardSection class="q-pa-lg">
|
||||
<div class="row items-center q-gutter-md">
|
||||
<QImg
|
||||
src="../image.png"
|
||||
alt="Scoreko logo"
|
||||
width="72px"
|
||||
height="72px"
|
||||
class="app-logo"
|
||||
fit="contain"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="text-h6">
|
||||
<div>
|
||||
<div class="text-h6 text-weight-bold">
|
||||
{{ appName }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-7">
|
||||
{{ t('aboutVersion') }} {{ currentVersion }}
|
||||
<div class="row items-center q-gutter-xs q-mt-xs">
|
||||
<QBadge
|
||||
outline
|
||||
color="primary"
|
||||
class="version-badge"
|
||||
>
|
||||
v{{ currentVersion }}
|
||||
</QBadge>
|
||||
<QBtn
|
||||
:href="`${repoUrl}/releases`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon="history"
|
||||
:label="t('aboutChangelog')"
|
||||
color="grey-6"
|
||||
flat
|
||||
dense
|
||||
no-caps
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</QCardSection>
|
||||
|
||||
<QSeparator />
|
||||
|
||||
<QCardSection>
|
||||
<p class="q-mb-sm">
|
||||
<!-- Description + framework -->
|
||||
<QCardSection class="q-pa-lg">
|
||||
<p class="text-body2 text-grey-7 q-mb-md">
|
||||
{{ t('aboutDescription') }}
|
||||
</p>
|
||||
<div class="column q-gutter-sm">
|
||||
<QBtn
|
||||
href="https://github.com/nodecg/nodecg"
|
||||
target="_blank"
|
||||
@@ -166,19 +108,43 @@ onMounted(() => {
|
||||
:label="t('aboutFrameworkNodeCG')"
|
||||
color="primary"
|
||||
flat
|
||||
dense
|
||||
no-caps
|
||||
align="left"
|
||||
/>
|
||||
</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 />
|
||||
|
||||
<QCardSection>
|
||||
<div class="text-subtitle2 q-mb-sm">
|
||||
<!-- Collaborators -->
|
||||
<QCardSection class="q-pa-lg">
|
||||
<div class="text-overline text-grey-6 q-mb-sm">
|
||||
{{ t('aboutCollaboratorsTitle') }}
|
||||
</div>
|
||||
<QList dense>
|
||||
<QList
|
||||
dense
|
||||
class="collaborators-list"
|
||||
>
|
||||
<QItem
|
||||
v-for="person in collaborators"
|
||||
:key="person.name"
|
||||
@@ -186,95 +152,101 @@ onMounted(() => {
|
||||
:href="person.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="collaborator-item rounded-borders"
|
||||
>
|
||||
<QItemSection avatar>
|
||||
<QIcon
|
||||
:name="person.icon"
|
||||
size="18px"
|
||||
color="primary"
|
||||
class="collaborator-icon"
|
||||
/>
|
||||
</QItemSection>
|
||||
<QItemSection>
|
||||
<QItemLabel>{{ person.name }}</QItemLabel>
|
||||
<QItemLabel caption>
|
||||
<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>
|
||||
</QCard>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<QCard
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<QCardSection>
|
||||
<div class="text-h6">
|
||||
{{ t('aboutUpdateSystemTitle') }}
|
||||
</div>
|
||||
<div class="text-body2 text-grey-7 q-mt-xs">
|
||||
{{ t('aboutUpdateSystemDescription') }}
|
||||
</div>
|
||||
</QCardSection>
|
||||
|
||||
<QSeparator />
|
||||
|
||||
<QCardSection class="q-gutter-md">
|
||||
<!-- Footer -->
|
||||
<QCardSection class="q-pa-md">
|
||||
<div class="row items-center justify-between">
|
||||
<span class="text-caption text-grey-5">
|
||||
© {{ currentYear }} Pandipipas · MIT License
|
||||
</span>
|
||||
<QBtn
|
||||
:label="t('aboutCheckUpdates')"
|
||||
color="primary"
|
||||
icon="sync"
|
||||
:loading="checkingUpdates"
|
||||
no-caps
|
||||
@click="checkForUpdates"
|
||||
/>
|
||||
|
||||
<QBanner
|
||||
v-if="latestRelease"
|
||||
rounded
|
||||
class="bg-grey-2"
|
||||
>
|
||||
<template #avatar>
|
||||
<QIcon
|
||||
:name="hasUpdate ? 'system_update_alt' : 'check_circle'"
|
||||
:color="hasUpdate ? 'warning' : 'positive'"
|
||||
/>
|
||||
</template>
|
||||
<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"
|
||||
:href="repoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon="open_in_new"
|
||||
label="GitHub"
|
||||
color="grey-6"
|
||||
flat
|
||||
dense
|
||||
no-caps
|
||||
size="sm"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
</QCardSection>
|
||||
</QCard>
|
||||
</div>
|
||||
</div>
|
||||
</QPage>
|
||||
</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>
|
||||
<QPage class="q-pa-lg">
|
||||
<div class="dashboard-panels q-mt-lg">
|
||||
<div class="dashboard-panels">
|
||||
<div class="dashboard-row dashboard-row--scoreboard">
|
||||
<QCard
|
||||
flat
|
||||
bordered
|
||||
class="dashboard-panel-card"
|
||||
>
|
||||
@@ -25,6 +26,7 @@ useHead({ title: 'Dashboard' });
|
||||
|
||||
<div class="dashboard-row dashboard-row--bottom">
|
||||
<QCard
|
||||
flat
|
||||
bordered
|
||||
class="dashboard-panel-card"
|
||||
>
|
||||
@@ -33,6 +35,7 @@ useHead({ title: 'Dashboard' });
|
||||
</QCardSection>
|
||||
</QCard>
|
||||
<QCard
|
||||
flat
|
||||
bordered
|
||||
class="dashboard-panel-card"
|
||||
>
|
||||
@@ -53,20 +56,12 @@ useHead({ title: 'Dashboard' });
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.dashboard-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-row--bottom {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.dashboard-panel-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-panel-content {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import bundlePackage from '../../../../package.json';
|
||||
import { graphicsSettingsReplicant } from '../../../browser_shared/replicants';
|
||||
import { t } from '../i18n';
|
||||
|
||||
defineOptions({ name: 'GraphicsView' });
|
||||
|
||||
import bundlePackage from '../../../../package.json';
|
||||
|
||||
type GraphicConfig = {
|
||||
name?: string;
|
||||
title?: string;
|
||||
@@ -130,19 +129,29 @@ const cards = computed<GraphicCard[]>(() => {
|
||||
return result;
|
||||
});
|
||||
|
||||
const copyUrl = async (graphic: GraphicConfig) => {
|
||||
const copiedCardId = ref<string | null>(null);
|
||||
|
||||
const copyUrl = async (graphic: GraphicConfig, cardId: string) => {
|
||||
const url = buildGraphicUrl(graphic);
|
||||
if (navigator.clipboard?.writeText) {
|
||||
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);
|
||||
}
|
||||
|
||||
copiedCardId.value = cardId;
|
||||
setTimeout(() => {
|
||||
copiedCardId.value = null;
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const openUrl = (graphic: GraphicConfig) => {
|
||||
window.open(buildGraphicUrl(graphic), '_blank');
|
||||
};
|
||||
|
||||
const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
|
||||
@@ -165,16 +174,18 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
|
||||
|
||||
<template>
|
||||
<QPage class="q-pa-lg">
|
||||
<div class="text-h4 q-mb-md">
|
||||
<div class="q-mb-lg">
|
||||
<div class="text-h5 text-weight-medium">
|
||||
{{ t('graphicsTitle') }}
|
||||
</div>
|
||||
<div class="text-body1 q-mb-lg">
|
||||
<div class="text-body2 text-grey-7 q-mt-xs">
|
||||
{{ t('graphicsDescription') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="cards.length === 0"
|
||||
class="text-body2 text-grey-5"
|
||||
class="text-body2 text-grey-6"
|
||||
>
|
||||
{{ t('graphicsNoConfigured') }}
|
||||
</div>
|
||||
@@ -194,7 +205,7 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
|
||||
<div class="text-h6">
|
||||
{{ card.label }}
|
||||
</div>
|
||||
<div class="text-caption text-grey-5">
|
||||
<div class="text-caption text-grey-4">
|
||||
{{ card.graphic.file }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,18 +235,27 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
|
||||
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<QBtn
|
||||
color="primary"
|
||||
icon="content_copy"
|
||||
:label="t('graphicsCopyUrl')"
|
||||
@click="copyUrl(card.graphic)"
|
||||
:color="copiedCardId === card.id ? 'positive' : 'primary'"
|
||||
:icon="copiedCardId === card.id ? 'check' : 'content_copy'"
|
||||
no-caps
|
||||
:label="copiedCardId === card.id ? t('graphicsCopied') : t('graphicsCopyUrl')"
|
||||
@click="copyUrl(card.graphic, card.id)"
|
||||
/>
|
||||
<QBtn
|
||||
color="secondary"
|
||||
icon="open_with"
|
||||
:label="t('graphicsDragObs')"
|
||||
no-caps
|
||||
draggable="true"
|
||||
:label="t('graphicsDragObs')"
|
||||
@dragstart="onDragStart($event, card.graphic)"
|
||||
/>
|
||||
<QBtn
|
||||
color="grey-7"
|
||||
icon="open_in_new"
|
||||
no-caps
|
||||
:label="t('graphicsOpenBrowser')"
|
||||
@click="openUrl(card.graphic)"
|
||||
/>
|
||||
</div>
|
||||
</QCardSection>
|
||||
</QCard>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useHead } from '@unhead/vue';
|
||||
|
||||
defineOptions({ name: 'PlayersView' });
|
||||
|
||||
import type { QTableColumn } from 'quasar';
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
|
||||
@@ -10,6 +7,8 @@ import type { Schemas } from '../../../types';
|
||||
import { locale, t } from '../i18n';
|
||||
import { usePlayersStore } from '../stores/players';
|
||||
|
||||
defineOptions({ name: 'PlayersView' });
|
||||
|
||||
useHead(() => ({ title: t('menuPlayers') }));
|
||||
|
||||
type PlayersMap = Schemas.Players;
|
||||
@@ -525,6 +524,20 @@ const hasChallongeTokenConfigured = computed(() => Boolean(challongeToken.value.
|
||||
|
||||
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) => {
|
||||
update(() => {
|
||||
const needle = value.toLowerCase().trim();
|
||||
@@ -685,6 +698,22 @@ const openSelectedTournamentImportDialog = () => {
|
||||
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 selectedPlayers = startGGPlayers.value.filter((player) =>
|
||||
selectedStartGGPlayerIds.value.includes(player.id),
|
||||
@@ -810,13 +839,14 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<QPage class="q-pa-lg players-page">
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="text-h4">
|
||||
<div class="text-h5 text-weight-medium">
|
||||
{{ t('menuPlayers') }}
|
||||
</div>
|
||||
<QSpace />
|
||||
<QBtn
|
||||
color="primary"
|
||||
icon="add"
|
||||
no-caps
|
||||
:label="t('playersNewPlayer')"
|
||||
class="q-ml-sm"
|
||||
@click="openCreateDialog"
|
||||
@@ -837,10 +867,12 @@ onBeforeUnmount(() => {
|
||||
<QIcon name="search" />
|
||||
</template>
|
||||
</QInput>
|
||||
<span class="text-caption text-grey-6">{{ rows.length }} players</span>
|
||||
<QBtn
|
||||
color="secondary"
|
||||
outline
|
||||
icon="file_upload"
|
||||
no-caps
|
||||
:label="t('playersImport')"
|
||||
@click="triggerImport"
|
||||
/>
|
||||
@@ -848,6 +880,7 @@ onBeforeUnmount(() => {
|
||||
color="secondary"
|
||||
outline
|
||||
icon="file_download"
|
||||
no-caps
|
||||
:label="t('playersExport')"
|
||||
@click="exportPlayers"
|
||||
/>
|
||||
@@ -871,6 +904,45 @@ onBeforeUnmount(() => {
|
||||
:filter="filter"
|
||||
: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 }">
|
||||
<QTd align="right">
|
||||
<QBtn
|
||||
@@ -908,7 +980,7 @@ onBeforeUnmount(() => {
|
||||
</svg>
|
||||
<span>start.gg</span>
|
||||
</div>
|
||||
<div class="text-caption q-mb-md">
|
||||
<div class="text-caption text-grey-6 q-mb-md">
|
||||
{{ t('playersStartggHelp') }}
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm items-center">
|
||||
@@ -917,6 +989,7 @@ onBeforeUnmount(() => {
|
||||
v-if="!hasStartGGTokenConfigured"
|
||||
color="primary"
|
||||
icon="login"
|
||||
no-caps
|
||||
:label="t('playersConnectStartgg')"
|
||||
:loading="oauthLoading"
|
||||
@click="connectWithStartGGOAuth"
|
||||
@@ -926,6 +999,7 @@ onBeforeUnmount(() => {
|
||||
outline
|
||||
color="positive"
|
||||
icon="check_circle"
|
||||
no-caps
|
||||
:label="t('playersConnected')"
|
||||
class="startgg-connected-btn"
|
||||
@click="openManualTokenDialog"
|
||||
@@ -936,6 +1010,7 @@ onBeforeUnmount(() => {
|
||||
outline
|
||||
color="white"
|
||||
icon="vpn_key"
|
||||
no-caps
|
||||
:label="t('playersUsePersonalApi')"
|
||||
@click="openManualTokenDialog"
|
||||
/>
|
||||
@@ -1020,7 +1095,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<span>Challonge</span>
|
||||
</div>
|
||||
<div class="text-caption q-mb-md">
|
||||
<div class="text-caption text-grey-6 q-mb-md">
|
||||
{{ t('playersChallongeHelp') }}
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm items-center">
|
||||
@@ -1029,6 +1104,7 @@ onBeforeUnmount(() => {
|
||||
v-if="!hasChallongeTokenConfigured"
|
||||
color="primary"
|
||||
icon="login"
|
||||
no-caps
|
||||
:label="t('playersConnectChallonge')"
|
||||
:loading="challongeOauthLoading"
|
||||
@click="connectWithChallongeOAuth"
|
||||
@@ -1038,6 +1114,7 @@ onBeforeUnmount(() => {
|
||||
outline
|
||||
:color="hasValidatedChallongeToken ? 'positive' : 'warning'"
|
||||
icon="check_circle"
|
||||
no-caps
|
||||
:label="challongeConnectionLabel"
|
||||
@click="openChallongeManualTokenDialog"
|
||||
/>
|
||||
@@ -1047,6 +1124,7 @@ onBeforeUnmount(() => {
|
||||
outline
|
||||
color="white"
|
||||
icon="vpn_key"
|
||||
no-caps
|
||||
:label="t('playersUsePersonalApi')"
|
||||
@click="openChallongeManualTokenDialog"
|
||||
/>
|
||||
@@ -1121,7 +1199,6 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<QDialog v-model="isManualTokenDialogOpen">
|
||||
<QCard class="players-dialog">
|
||||
<QCardSection>
|
||||
@@ -1153,17 +1230,20 @@ onBeforeUnmount(() => {
|
||||
<QCardActions align="right">
|
||||
<QBtn
|
||||
flat
|
||||
no-caps
|
||||
label="Cancel"
|
||||
color="secondary"
|
||||
@click="isManualTokenDialogOpen = false"
|
||||
/>
|
||||
<QBtn
|
||||
flat
|
||||
no-caps
|
||||
color="negative"
|
||||
label="Delete token"
|
||||
@click="manualTokenDraft = ''; saveManualToken()"
|
||||
/>
|
||||
<QBtn
|
||||
no-caps
|
||||
color="primary"
|
||||
label="Save token"
|
||||
@click="saveManualToken"
|
||||
@@ -1189,6 +1269,20 @@ onBeforeUnmount(() => {
|
||||
<span>Loading participants...</span>
|
||||
</div>
|
||||
<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
|
||||
v-model="selectedStartGGPlayerIds"
|
||||
type="checkbox"
|
||||
@@ -1203,11 +1297,13 @@ onBeforeUnmount(() => {
|
||||
<QCardActions align="right">
|
||||
<QBtn
|
||||
flat
|
||||
no-caps
|
||||
label="Cancel"
|
||||
color="secondary"
|
||||
@click="isImportDialogOpen = false"
|
||||
/>
|
||||
<QBtn
|
||||
no-caps
|
||||
color="primary"
|
||||
label="Import selected"
|
||||
:disable="!selectedStartGGPlayerIds.length"
|
||||
@@ -1234,6 +1330,20 @@ onBeforeUnmount(() => {
|
||||
<span>Loading participants...</span>
|
||||
</div>
|
||||
<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
|
||||
v-model="selectedChallongePlayerIds"
|
||||
type="checkbox"
|
||||
@@ -1248,11 +1358,13 @@ onBeforeUnmount(() => {
|
||||
<QCardActions align="right">
|
||||
<QBtn
|
||||
flat
|
||||
no-caps
|
||||
label="Cancel"
|
||||
color="secondary"
|
||||
@click="challongeImportDialogOpen = false"
|
||||
/>
|
||||
<QBtn
|
||||
no-caps
|
||||
color="primary"
|
||||
label="Import selected"
|
||||
:disable="!selectedChallongePlayerIds.length"
|
||||
@@ -1286,17 +1398,20 @@ onBeforeUnmount(() => {
|
||||
<QCardActions align="right">
|
||||
<QBtn
|
||||
flat
|
||||
no-caps
|
||||
label="Cancel"
|
||||
color="secondary"
|
||||
@click="isChallongeManualTokenDialogOpen = false"
|
||||
/>
|
||||
<QBtn
|
||||
flat
|
||||
no-caps
|
||||
color="negative"
|
||||
label="Delete token"
|
||||
@click="challongeManualTokenDraft = ''; saveChallongeManualToken()"
|
||||
/>
|
||||
<QBtn
|
||||
no-caps
|
||||
color="primary"
|
||||
label="Save token"
|
||||
@click="saveChallongeManualToken"
|
||||
@@ -1323,6 +1438,8 @@ onBeforeUnmount(() => {
|
||||
dense
|
||||
class="players-underlined-field"
|
||||
autofocus
|
||||
:rules="[(v) => !!v?.trim() || 'Gamertag is required']"
|
||||
lazy-rules
|
||||
/>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
@@ -1376,11 +1493,13 @@ onBeforeUnmount(() => {
|
||||
<QCardActions align="right">
|
||||
<QBtn
|
||||
flat
|
||||
no-caps
|
||||
label="Cancel"
|
||||
color="secondary"
|
||||
@click="isDialogOpen = false"
|
||||
/>
|
||||
<QBtn
|
||||
no-caps
|
||||
color="primary"
|
||||
label="Save"
|
||||
@click="savePlayer"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { useHead } from '@unhead/vue';
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import type { Locale } from '../i18n';
|
||||
import { locale, setLocale, t } from '../i18n';
|
||||
import {
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
|
||||
defineOptions({ name: 'SettingsView' });
|
||||
|
||||
useHead(() => ({ title: t('settingsTitle') }));
|
||||
|
||||
const languageOptions = computed(() => [
|
||||
{ label: t('languageSpanish'), value: 'es' as const },
|
||||
{ label: t('languageEnglish'), value: 'en' as const },
|
||||
@@ -26,6 +28,9 @@ const selectedLanguage = computed<Locale>({
|
||||
const shortcutSettingsStore = useShortcutSettingsStore();
|
||||
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 }[]>(() => [
|
||||
{ action: 'leftIncrement', label: t('settingsShortcutLeftIncrementLabel'), hint: t('settingsShortcutLeftIncrementHint') },
|
||||
{ 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') },
|
||||
]);
|
||||
|
||||
// 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 = () => {
|
||||
recordingAction.value = null;
|
||||
if (typeof document !== 'undefined') {
|
||||
@@ -41,20 +61,34 @@ const stopRecording = () => {
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const shortcut = eventToShortcut(event);
|
||||
if (!shortcut) {
|
||||
return;
|
||||
}
|
||||
if (!shortcut) return;
|
||||
|
||||
event.preventDefault();
|
||||
shortcutSettingsStore.setShortcut(recordingAction.value, shortcut);
|
||||
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) => {
|
||||
if (recordingAction.value === action) {
|
||||
stopRecording();
|
||||
@@ -69,55 +103,61 @@ const startRecording = (action: ShortcutAction) => {
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('keydown', onRecordKeydown);
|
||||
document.addEventListener('mousedown', onDocumentMousedown);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('keydown', onRecordKeydown);
|
||||
document.removeEventListener('mousedown', onDocumentMousedown);
|
||||
}
|
||||
stopRecording();
|
||||
});
|
||||
|
||||
useHead(() => ({ title: t('settingsTitle') }));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<QPage class="q-pa-lg">
|
||||
<div class="text-h4 q-mb-md">
|
||||
<div class="q-mb-lg">
|
||||
<div class="text-h5 text-weight-medium">
|
||||
{{ t('settingsTitle') }}
|
||||
</div>
|
||||
<div class="text-body1 q-mb-lg">
|
||||
<div class="text-body2 text-grey-7 q-mt-xs">
|
||||
{{ t('settingsDescription') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QCard
|
||||
flat
|
||||
bordered
|
||||
class="q-pa-md settings-card"
|
||||
class="settings-card"
|
||||
>
|
||||
<QCardSection class="q-pa-none q-mb-lg">
|
||||
<div class="text-subtitle1 q-mb-sm">
|
||||
{{ t('settingsLanguageLabel') }}
|
||||
</div>
|
||||
|
||||
<!-- Language -->
|
||||
<QCardSection class="q-pa-lg">
|
||||
<!--
|
||||
Label movido al propio QSelect (más idiomático en Quasar con outlined).
|
||||
Se elimina el text-overline redundante de encima.
|
||||
-->
|
||||
<QSelect
|
||||
v-model="selectedLanguage"
|
||||
emit-value
|
||||
map-options
|
||||
:label="t('settingsLanguageLabel')"
|
||||
: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') }}
|
||||
</div>
|
||||
</QCardSection>
|
||||
|
||||
<QSeparator class="q-mb-lg" />
|
||||
<QSeparator />
|
||||
|
||||
<QCardSection class="q-pa-none">
|
||||
<div class="row items-center justify-between q-mb-sm">
|
||||
<div class="text-subtitle1">
|
||||
<!-- Shortcuts -->
|
||||
<QCardSection class="q-pa-lg">
|
||||
<div class="row items-center justify-between q-mb-xs">
|
||||
<div class="text-overline text-grey-6">
|
||||
{{ t('settingsShortcutTitle') }}
|
||||
</div>
|
||||
<QBtn
|
||||
@@ -133,30 +173,77 @@ useHead(() => ({ title: t('settingsTitle') }));
|
||||
</QBtn>
|
||||
</div>
|
||||
|
||||
<div class="text-caption text-grey-5 q-mb-md">
|
||||
<div class="text-caption text-grey-6 q-mb-lg">
|
||||
{{ t('settingsShortcutDescription') }}
|
||||
</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
|
||||
v-for="field in shortcutFields"
|
||||
:key="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
|
||||
outlined
|
||||
dense
|
||||
bottom-slots
|
||||
:label="field.label"
|
||||
>
|
||||
<template #append>
|
||||
<!-- Botón grabar / detener -->
|
||||
<QBtn
|
||||
flat
|
||||
round
|
||||
dense
|
||||
:icon="recordingAction === field.action ? 'stop_circle' : 'keyboard'"
|
||||
:color="recordingAction === field.action ? 'negative' : 'primary'"
|
||||
:aria-label="
|
||||
recordingAction === field.action
|
||||
? t('settingsShortcutStopRecording')
|
||||
: t('settingsShortcutStartRecording')
|
||||
"
|
||||
@click="startRecording(field.action)"
|
||||
/>
|
||||
</template>
|
||||
<template #hint>
|
||||
{{ recordingAction === field.action ? t('settingsShortcutRecordingHint') : field.hint }}
|
||||
|
||||
<!-- Botón reset individual por atajo -->
|
||||
<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>
|
||||
</QInput>
|
||||
</div>
|
||||
@@ -167,6 +254,6 @@ useHead(() => ({ title: t('settingsTitle') }));
|
||||
|
||||
<style scoped>
|
||||
.settings-card {
|
||||
max-width: 720px;
|
||||
max-width: 600px;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 165 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 229 KiB |
@@ -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). */
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"typeRoots": [
|
||||
"./node_modules/@types"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
/* Settings used for anything extension related. */
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"typeRoots": [
|
||||
|
||||
@@ -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. */
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"typeRoots": [
|
||||
|
||||