27 Commits

Author SHA1 Message Date
Pandipipas 20cc81e696 Merge pull request #153 from Pandipipas/make-asset-downloads-permanent
Make downloaded game assets persistent and usable offline
2026-03-04 16:54:49 +01:00
Pandipipas 4db5c89f0a Cache game titles for offline selector labels 2026-03-04 16:52:16 +01:00
Pandipipas 752232eeca Store persistent assets in bundle-local db directory 2026-03-04 16:45:48 +01:00
Pandipipas c1e9133970 Make downloaded game assets persistent and offline-friendly 2026-03-04 16:10:08 +01:00
Pandipipas 774bd373d3 chore: add pnpm workspace configuration with only built dependencies 2026-03-04 14:50:51 +01:00
Pandipipas e922a6061e feat: remove deprecated character images and README for character catalog 2026-03-03 23:09:12 +01:00
Pandipipas dbbf17c917 Merge pull request #152 from Pandipipas/add-slug-formatting-for-game-names
Soportar nombres visibles de juegos mediante `games/games.json`
2026-03-03 23:04:16 +01:00
Pandipipas 097c9014f9 Mostrar nombre personalizado de juego en selector del dashboard 2026-03-03 21:52:53 +01:00
Pandipipas 2b2dd3180b Soportar nombres de juegos personalizados desde games.json 2026-03-03 21:45:55 +01:00
Pandipipas 69cb280ec1 Merge pull request #151 from Pandipipas/update-gameassetsview-to-auto-load-games
feat: descubrir y mostrar juegos desde el servidor HTTP de assets
2026-03-03 21:22:44 +01:00
Pandipipas 586c95ec11 feat: cargar juegos de assets desde servidor HTTP 2026-03-03 21:06:51 +01:00
Pandipipas 98f08e39d3 Merge pull request #150 from Pandipipas/cache-logos-from-http-server
Use configurable assets base URL for game logos and construct remote asset paths
2026-03-03 20:59:20 +01:00
Pandipipas c8097a72d8 Document how to host remote game logos on HTTP server 2026-03-03 20:59:03 +01:00
Pandipipas f91d5eaf48 Serve game logos directly from assets HTTP server 2026-03-03 20:44:19 +01:00
Pandipipas 5e6276ee19 Merge pull request #149 from Pandipipas/migrate-assets-to-local-server-1rhwip
Switch game assets to HTTP server (remove GitHub API) and require assetsBaseUrl
2026-03-03 20:28:06 +01:00
Pandipipas d4fe407b92 fix: relax bundle config requirements and default assetsBaseUrl 2026-03-03 20:24:56 +01:00
Pandipipas a93492b86b refactor: remove GitHub assets source and require HTTP provider 2026-03-03 20:01:13 +01:00
Pandipipas 13db5528a8 Merge pull request #146 from Pandipipas/add-character-names-download-with-assets
Descargar y usar listas de personajes por juego desde los assets
2026-03-03 16:22:12 +01:00
Pandipipas fc82c9215a Load character names from downloaded game assets 2026-03-03 16:15:11 +01:00
Pandipipas 584f872954 Merge pull request #145 from Pandipipas/add-new-view-for-fighting-games-uyw5fv
Add game assets manager (backend, store, UI) and character image fallbacks
2026-03-03 15:48:24 +01:00
Pandipipas 057b5a29c3 Remove tracked PNG game logos from repository 2026-03-03 15:44:47 +01:00
Pandipipas 1735e38edd Merge pull request #143 from Pandipipas/adapta-bundle-a-nueva-dependencia-nodecg
Bump Node.js engine, simplify start script, and add usage to README
2026-03-03 12:29:50 +01:00
Pandipipas f8ffad02cb Update docs to require Node.js 24.14.0+ 2026-03-03 12:26:58 +01:00
Pandipipas d289b7e0b7 Merge pull request #142 from Pandipipas/node24-deps
Update Node.js version to 24.14.0 in .nvmrc and package.json; adjust …
2026-03-01 15:23:29 +01:00
Pandipipas 27e2e441c0 fixes 2026-03-01 15:21:34 +01:00
Pandipipas 7ec56575d1 Refactor scoreboard settings layout for improved usability; consolidate options into a single sectioned card and enhance input styling. 2026-03-01 15:03:50 +01:00
Pandipipas d26e0df713 Update Node.js version to 24.14.0 in .nvmrc and package.json; adjust TypeScript configurations to extend from @tsconfig/node24. Reorganize dependencies and devDependencies for clarity. 2026-03-01 14:53:16 +01:00
152 changed files with 3934 additions and 3463 deletions
+4 -9
View File
@@ -23,22 +23,17 @@ 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: 24
cache: pnpm
cache: npm
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: npm ci
- name: Lint
run: pnpm run lint
run: npm run lint
- name: Build
run: pnpm run build
run: npm run build
+5 -12
View File
@@ -1,7 +1,7 @@
# Logs
logs
*.log
pnpm-debug.log*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
@@ -48,11 +48,8 @@ web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional pnpm cache directory
.pnpm-store
.corepack/
.npm-cache/
.node-gyp/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
@@ -69,7 +66,7 @@ web_modules/
# Optional REPL history
.node_repl_history
# Output of 'pnpm pack'
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
@@ -137,8 +134,4 @@ dist
/extension/
/graphics/
/shared/dist/
# Local runtime database
/db/
*.sqlite3
/scoreko-electron-dev/
/db/
+1
View File
@@ -0,0 +1 @@
24.14.0
+103 -8
View File
@@ -9,18 +9,113 @@ NodeCG bundle for producing fighting game overlays.
## Requirements
- Node.js 22+
- NodeCG 2.3+
- Node.js 24.14.0+
## Scripts
- `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.
- `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 using the local dependency (`nodecg start`).
- `npm run watch`: development mode with watch.
## Usage
- `npm install`
- `npm run build`
- `npm run start` (equivalent to `npx nodecg start`)
## Version
Initial project version: `0.1.0`.
## Assets por HTTP (sin GitHub API)
La descarga de assets usa **únicamente HTTP**. Debes configurar un servidor propio.
1. En `cfg/scoreko-dev.json`, configura `assetsBaseUrl` (opcional, por defecto `http://localhost`):
```json
{
"scoreko-dev": {
"assetsBaseUrl": "http://localhost"
}
}
```
2. Sirve por HTTP esta estructura:
```text
games/
games.json (opcional, para nombres visibles personalizados)
street-fighter-6/
street-fighter-6.png
manifest.json
fighting-characters.json
characters/...
tekken-8/
tekken-8.png
manifest.json
...
```
`games/games.json` es opcional y permite mapear `slug -> nombre visible`.
Formato objeto:
```json
{
"2xko": "2XKO",
"tekken-8": "Tekken 8"
}
```
También se acepta array de objetos:
```json
[
{ "slug": "2xko", "title": "2XKO" },
{ "slug": "tekken-8", "title": "Tekken 8" }
]
```
## Logos en servidor HTTP (sin logos locales en el bundle)
La vista de "Game Assets" carga los logos directamente desde:
```text
{assetsBaseUrl}/games/{repoFolder}/{logoFile}
```
Ejemplos:
- `http://TU_SERVIDOR/games/street-fighter-6/street-fighter-6.png`
- `http://TU_SERVIDOR/games/tekken-8/tekken-8.png`
### Cómo guardarlos en la carpeta HTTP
1. Crea la carpeta del juego en tu web root (si no existe).
2. Copia el logo con el nombre esperado (`logoFile` de `src/shared/fighting-games.ts`).
3. Verifica desde navegador o `curl` que responde `200`.
Ejemplo rápido en Linux (Nginx/Apache):
```bash
sudo mkdir -p /var/www/assets/games/street-fighter-6
sudo cp ./street-fighter-6.png /var/www/assets/games/street-fighter-6/street-fighter-6.png
curl -I http://TU_SERVIDOR/games/street-fighter-6/street-fighter-6.png
```
Opcional (recomendado): añade cache HTTP (`Cache-Control`, `ETag`) en tu servidor para que el navegador no los vuelva a descargar en cada visita.
3. Cada `manifest.json` debe ser un array con rutas relativas, o con objetos `{ "path", "size", "url" }`.
Ejemplo mínimo:
```json
[
"fighting-characters.json",
"characters/ryu.png"
]
```
+8 -4
View File
@@ -4,7 +4,8 @@
"additionalProperties": false,
"properties": {
"exampleProperty": {
"type": "string"
"type": "string",
"default": ""
},
"startggClientId": {
"type": "string",
@@ -39,9 +40,12 @@
"minimum": 1,
"maximum": 65535,
"description": "Puerto local para callback OAuth de Challonge"
},
"assetsBaseUrl": {
"type": "string",
"default": "http://localhost",
"description": "URL base para descargar assets por HTTP (ej: http://192.168.1.50:8080). Por defecto usa http://localhost."
}
},
"required": [
"exampleProperty"
]
"required": []
}
+34 -37
View File
@@ -12,52 +12,21 @@
},
"license": "MIT",
"author": "Pandipipas",
"packageManager": "pnpm@11.0.8",
"type": "module",
"engines": {
"node": ">=24.14.0"
},
"scripts": {
"autofix": "eslint --fix",
"prebuild": "trash ./extension && trash ./node_modules/.vite && trash ./shared/dist && trash ./dashboard && trash ./graphics",
"build": "vite build && tsc -b tsconfig.extension.json",
"lint": "eslint",
"schema-types": "nodecg schema-types",
"start": "cd ../.. && node index.js",
"start": "nodecg start",
"watch": "conc -n B,E -c red,blue -k vite \"tsc -b -w --preserveWatchOutput tsconfig.extension.json\""
},
"devDependencies": {
"@eslint/js": "^9.39.0",
"@quasar/extras": "^1.17.0",
"@quasar/vite-plugin": "^1.10.0",
"@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",
"@vue/tsconfig": "^0.8.1",
"concurrently": "^9.2.1",
"eslint": "^9.39.0",
"eslint-plugin-vue": "^10.5.1",
"nodecg": "^2.6.1",
"nodecg-vue-composable": "^1.1.0",
"pinia": "^2.3.1",
"quasar": "^2.18.5",
"sass-embedded": "^1.93.3",
"trash-cli": "^7.0.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2",
"vite": "^7.1.12",
"vite-plugin-checker": "^0.11.0",
"vite-plugin-nodecg": "^2.1.0",
"vue": "^3.5.22",
"vue-router": "^4.5.0",
"vue-tsc": "^3.1.2"
},
"pnpm": {
"overrides": {
"vite-plugin-nodecg>vite": "$vite"
}
},
"nodecg": {
"compatibleRange": "^2.3.0",
"compatibleRange": "^2.6.0",
"dashboardPanels": [
{
"name": "scoreko-dev",
@@ -102,7 +71,35 @@
}
},
"dependencies": {
"@quasar/extras": "^1.17.0",
"@unhead/vue": "^2.0.19",
"country-list": "^2.4.1",
"flag-icons": "^7.5.0"
"flag-icons": "^7.5.0",
"nodecg": "^2.6.4",
"nodecg-vue-composable": "^1.1.0",
"pinia": "^2.3.1",
"quasar": "^2.18.5",
"vue": "^3.5.22",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.0",
"@quasar/vite-plugin": "^1.10.0",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^22.18.13",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.8.1",
"concurrently": "^9.2.1",
"eslint": "^9.39.0",
"eslint-plugin-vue": "^10.5.1",
"sass-embedded": "^1.93.3",
"trash-cli": "^7.0.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2",
"vite": "^7.1.12",
"vite-plugin-checker": "^0.11.0",
"vite-plugin-nodecg": "^2.1.0",
"vue-tsc": "^3.1.2"
}
}
+1009 -1045
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,7 +1,7 @@
allowBuilds:
'@parcel/watcher': true
'@vaadin/vaadin-usage-statistics': true
better-sqlite3: true
esbuild: true
msgpackr-extract: true
vue-demi: true
onlyBuiltDependencies:
- '@parcel/watcher'
- '@vaadin/vaadin-usage-statistics'
- better-sqlite3
- esbuild
- msgpackr-extract
- vue-demi
@@ -1,12 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { 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',
@@ -27,7 +25,7 @@ const stageOptions = [
const bracketSideOptions = [
{ label: 'None', value: '' },
{ label: 'Winners', value: 'Winners' },
{ label: 'Losers', value: 'Losers' },
{ label: 'Loosers', value: 'Loosers' },
];
const stage = ref(stageOptions[0]);
@@ -36,14 +34,6 @@ 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) {
@@ -100,14 +90,8 @@ watch(customActive, (value) => {
});
watch(customText, (value) => {
if (customDeactivateTimer) {
clearTimeout(customDeactivateTimer);
}
if (!value.trim()) {
customDeactivateTimer = setTimeout(() => {
customActive.value = false;
customDeactivateTimer = null;
}, 600);
customActive.value = false;
}
});
@@ -128,7 +112,6 @@ onMounted(() => {
v-model="stage"
:label="t('bracketStage')"
:options="stageOptions"
:disable="customActive"
dense
class="bracket-panel__field"
/>
@@ -136,7 +119,6 @@ onMounted(() => {
v-model="bracketSide"
:label="t('bracketSide')"
:options="bracketSideOptions"
:disable="customActive"
dense
emit-value
map-options
@@ -147,7 +129,6 @@ onMounted(() => {
v-model="customText"
:label="t('bracketCustomProgress')"
dense
clearable
class="bracket-panel-custom-input bracket-panel__field"
/>
<QToggle
@@ -157,17 +138,6 @@ 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>
@@ -213,36 +183,4 @@ 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>
</style>
@@ -1,65 +1,8 @@
<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>
@@ -71,9 +14,7 @@ const rightHandlePreview = computed(() =>
</div>
<div class="commentary-panel__layout">
<!-- Commentator 1 -->
<div class="commentary-panel__commentator">
<QInput
v-model="commentaryStore.leftCommentator"
:label="t('commentaryCommentator1')"
@@ -86,58 +27,23 @@ const rightHandlePreview = computed(() =>
</QInput>
<QInput
:model-value="commentaryStore.leftCommentatorTwitter"
v-model="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
round
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="swap_horiz"
class="commentary-panel__swap-btn"
@click="commentaryStore.swapCommentators"
/>
<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')"
@@ -150,23 +56,11 @@ const rightHandlePreview = computed(() =>
</QInput>
<QInput
:model-value="commentaryStore.rightCommentatorTwitter"
v-model="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>
@@ -195,35 +89,6 @@ const rightHandlePreview = computed(() =>
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;
@@ -247,27 +112,14 @@ const rightHandlePreview = computed(() =>
color: rgba(255, 255, 255, 0.92);
}
/* 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 {
color: #fff;
opacity: 0.85;
}
.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);
.commentary-panel__swap-btn:hover {
opacity: 1;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.45);
}
@media (max-width: 900px) {
@@ -275,9 +127,8 @@ const rightHandlePreview = computed(() =>
grid-template-columns: 1fr;
}
.commentary-panel__center-controls {
.commentary-panel__swap-btn {
justify-self: center;
flex-direction: row;
}
}
</style>
</style>
@@ -1,503 +0,0 @@
<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>
@@ -1,191 +0,0 @@
<script setup lang="ts">
import { inject } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { t } from '../i18n';
const scoreboardStore = useScoreboardStore();
const { gameInput, fightingGameOptions, onGameFilter } = inject(CHARACTER_GAME_KEY)!;
const adjustLeftScore = (delta: number) => {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta);
};
const adjustRightScore = (delta: number) => {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta);
};
</script>
<template>
<div class="scoreboard-preview__center">
<QSelect
v-model="scoreboardStore.scoreboard.game"
v-model:input-value="gameInput"
:options="fightingGameOptions"
:label="t('scoreboardLabelGame')"
dense
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
class="scoreboard-preview__field scoreboard-preview__game-field"
@filter="onGameFilter"
>
<template #prepend>
<QIcon name="sports_esports" />
</template>
</QSelect>
<div class="scoreboard-preview__score-controls">
<div class="scoreboard-preview__score-side">
<QBtn
flat
dense
round
size="sm"
icon="add"
@click="adjustLeftScore(1)"
/>
<span class="scoreboard-preview__score-value">
{{ scoreboardStore.scoreboard.leftScore }}
</span>
<QBtn
flat
dense
round
size="sm"
icon="remove"
@click="adjustLeftScore(-1)"
/>
</div>
<span class="scoreboard-preview__dash">-</span>
<div class="scoreboard-preview__score-side">
<QBtn
flat
dense
round
size="sm"
icon="add"
@click="adjustRightScore(1)"
/>
<span class="scoreboard-preview__score-value">
{{ scoreboardStore.scoreboard.rightScore }}
</span>
<QBtn
flat
dense
round
size="sm"
icon="remove"
@click="adjustRightScore(-1)"
/>
</div>
</div>
<div class="scoreboard-preview__actions">
<QBtn
flat
dense
icon="swap_horiz"
class="scoreboard-preview__action-btn"
@click="scoreboardStore.swapPlayers"
/>
<QBtn
flat
dense
icon="restart_alt"
class="scoreboard-preview__action-btn"
@click="scoreboardStore.resetScores"
/>
</div>
</div>
</template>
<style scoped>
.scoreboard-preview__center {
display: flex;
flex-direction: column;
align-items: center;
align-self: stretch;
justify-content: flex-start;
padding-top: 2px;
gap: 10px;
}
.scoreboard-preview__game-field {
width: min(100%, 240px);
margin-bottom: 56px;
}
.scoreboard-preview__score-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
}
.scoreboard-preview__score-side {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.scoreboard-preview__score-value {
min-width: 64px;
text-align: center;
font-size: clamp(4rem, 7vw, 5.6rem);
font-weight: 800;
line-height: 1;
}
.scoreboard-preview__dash {
opacity: 0.7;
font-size: clamp(3rem, 5vw, 4rem);
font-weight: 700;
}
.scoreboard-preview__actions {
display: flex;
align-items: center;
gap: 10px;
}
.scoreboard-preview__action-btn {
color: #fff;
opacity: 0.85;
}
.scoreboard-preview__action-btn:hover {
opacity: 1;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.45);
}
/* Shared field styles (used by QSelect inside this panel) */
.scoreboard-preview__field {
margin: 0;
}
.scoreboard-preview__field :deep(.q-field__control) {
min-height: 28px;
padding: 0;
background: transparent !important;
border-radius: 0;
}
.scoreboard-preview__field :deep(.q-field__control:before),
.scoreboard-preview__field :deep(.q-field__control:after) {
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
}
.scoreboard-preview__field :deep(.q-field__native),
.scoreboard-preview__field :deep(.q-field__input),
.scoreboard-preview__field :deep(.q-field__label) {
color: rgba(255, 255, 255, 0.92);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,205 +0,0 @@
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,
};
}
@@ -1,36 +0,0 @@
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 };
}
@@ -1,346 +0,0 @@
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,
};
}
+3 -39
View File
@@ -6,6 +6,7 @@ type Translations = {
menuDashboard: string;
menuPlayers: string;
menuGraphics: string;
menuAssets: string;
menuSettings: string;
menuAbout: string;
settingsTitle: string;
@@ -69,7 +70,6 @@ type Translations = {
bracketStage: string;
bracketSide: string;
bracketCustomProgress: string;
bracketPreview: string;
playersLabelTeam: string;
playersLabelCountry: string;
playersLabelActions: string;
@@ -85,18 +85,6 @@ 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';
@@ -106,6 +94,7 @@ const messages: Record<Locale, Translations> = {
menuDashboard: 'Dashboard',
menuPlayers: 'Players',
menuGraphics: 'Graphics',
menuAssets: 'Game Assets',
menuSettings: 'Settings',
menuAbout: 'About',
settingsTitle: 'Settings',
@@ -169,7 +158,6 @@ const messages: Record<Locale, Translations> = {
bracketStage: 'Stage',
bracketSide: 'Bracket side',
bracketCustomProgress: 'Custom progress',
bracketPreview: 'Preview',
playersLabelTeam: 'Team',
playersLabelCountry: 'Country',
playersLabelActions: 'Actions',
@@ -185,23 +173,12 @@ 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',
menuPlayers: 'Jugadores',
menuGraphics: 'Gráficos',
menuAssets: 'Assets de juego',
menuSettings: 'Configuración',
menuAbout: 'Acerca de',
settingsTitle: 'Configuración',
@@ -265,7 +242,6 @@ const messages: Record<Locale, Translations> = {
bracketStage: 'Etapa',
bracketSide: 'Lado del bracket',
bracketCustomProgress: 'Progreso personalizado',
bracketPreview: 'Vista previa',
playersLabelTeam: 'Equipo',
playersLabelCountry: 'País',
playersLabelActions: 'Acciones',
@@ -281,18 +257,6 @@ 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',
},
};
+1
View File
@@ -8,6 +8,7 @@ const menuItems = computed(() => [
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' },
{ label: t('menuPlayers'), to: '/players', icon: 'groups' },
{ label: t('menuGraphics'), to: '/graphics', icon: 'collections' },
{ label: t('menuAssets'), to: '/game-assets', icon: 'sports_esports' },
{ label: t('menuSettings'), to: '/settings', icon: 'settings' },
{ label: t('menuAbout'), to: '/about', icon: 'info' },
]);
+2
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
import AboutView from './views/About.vue';
import DashboardView from './views/Dashboard.vue';
import GraphicsView from './views/Graphics.vue';
import GameAssetsView from './views/GameAssets.vue';
import PlayersView from './views/Players.vue';
import SettingsView from './views/Settings.vue';
@@ -11,6 +12,7 @@ const router = createRouter({
{ path: '/', name: 'dashboard', component: DashboardView },
{ path: '/players', name: 'players', component: PlayersView },
{ path: '/graphics', name: 'graphics', component: GraphicsView },
{ path: '/game-assets', name: 'game-assets', component: GameAssetsView },
{ path: '/settings', name: 'settings', component: SettingsView },
{ path: '/about', name: 'about', component: AboutView },
],
@@ -0,0 +1,154 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
type DownloadStatus = 'downloading' | 'completed' | 'error';
type ProgressPayload = {
title: string;
progress: number;
status: DownloadStatus;
};
type RemoteGame = {
title: string;
slug: string;
repoFolder: string;
logoFile: string;
};
const sendNodecgMessage = <TResponse>(messageName: string, payload?: unknown) => new Promise<TResponse>((resolve, reject) => {
nodecg.sendMessage(messageName, payload, (error: unknown, response: unknown) => {
if (error) {
reject(error instanceof Error ? error : new Error(String(error)));
return;
}
resolve(response as TResponse);
});
});
let progressListenerAttached = false;
export const useGameAssetsStore = defineStore('game-assets', () => {
const installedGames = ref<string[]>([]);
const availableGames = ref<RemoteGame[]>([]);
const characterNamesByGame = ref<Record<string, string[]>>({});
const loadingByTitle = ref<Record<string, boolean>>({});
const removingByTitle = ref<Record<string, boolean>>({});
const progressByTitle = ref<Record<string, number>>({});
const assetsBaseUrl = ref('http://localhost');
if (!progressListenerAttached) {
nodecg.listenFor('scoreko-assets:downloadProgress', (payload: unknown) => {
const message = payload as Partial<ProgressPayload>;
if (typeof message.title !== 'string') {
return;
}
if (typeof message.progress === 'number') {
progressByTitle.value = {
...progressByTitle.value,
[message.title]: message.progress,
};
}
if (message.status === 'completed' || message.status === 'error') {
loadingByTitle.value = {
...loadingByTitle.value,
[message.title]: false,
};
}
});
progressListenerAttached = true;
}
const refreshCharacterNamesByGame = async () => {
const response = await sendNodecgMessage<Record<string, string[]>>('scoreko-assets:listCharactersByGame');
characterNamesByGame.value = response;
return characterNamesByGame.value;
};
const refreshInstalledGames = async () => {
try {
const availableResponse = await sendNodecgMessage<RemoteGame[]>('scoreko-assets:listRemoteGames');
availableGames.value = Array.isArray(availableResponse) ? availableResponse : [];
} catch {
availableGames.value = [];
}
const response = await sendNodecgMessage<string[]>('scoreko-assets:listInstalled');
installedGames.value = Array.isArray(response) ? response : [];
const configResponse = await sendNodecgMessage<{ assetsBaseUrl?: string }>('scoreko-assets:getAssetsBaseUrl');
assetsBaseUrl.value = typeof configResponse?.assetsBaseUrl === 'string' && configResponse.assetsBaseUrl.trim()
? configResponse.assetsBaseUrl
: 'http://localhost';
await refreshCharacterNamesByGame();
return installedGames.value;
};
const downloadGame = async (slug: string) => {
loadingByTitle.value = {
...loadingByTitle.value,
[slug]: true,
};
progressByTitle.value = {
...progressByTitle.value,
[slug]: 0,
};
try {
const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:downloadGame', { slug });
installedGames.value = response.installedGames;
await refreshCharacterNamesByGame();
loadingByTitle.value = {
...loadingByTitle.value,
[slug]: false,
};
progressByTitle.value = {
...progressByTitle.value,
[slug]: 100,
};
return response;
} catch (error) {
loadingByTitle.value = {
...loadingByTitle.value,
[slug]: false,
};
throw error;
}
};
const removeGame = async (slug: string) => {
removingByTitle.value = {
...removingByTitle.value,
[slug]: true,
};
try {
const response = await sendNodecgMessage<{ installedGames: string[] }>('scoreko-assets:removeGame', { slug });
installedGames.value = response.installedGames;
await refreshCharacterNamesByGame();
return response;
} finally {
removingByTitle.value = {
...removingByTitle.value,
[slug]: false,
};
}
};
return {
installedGames,
availableGames,
characterNamesByGame,
loadingByTitle,
removingByTitle,
progressByTitle,
assetsBaseUrl,
refreshInstalledGames,
refreshCharacterNamesByGame,
downloadGame,
removeGame,
};
});
@@ -175,15 +175,9 @@ export const useShortcutSettingsStore = defineStore('shortcut-settings', () => {
persistSettings(shortcuts);
};
const resetShortcut = (action: ShortcutAction) => {
shortcuts[action] = defaultShortcuts[action];
persistSettings(shortcuts);
};
return {
shortcuts,
setShortcut,
resetShortcuts,
resetShortcut,
};
});
});
+233 -205
View File
@@ -1,252 +1,280 @@
<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 repoUrl = 'https://github.com/Pandipipas/scoreko-dev';
const authorUrl = 'https://github.com/Pandipipas';
const updateRepoOwner = 'Pandipipas';
const updateRepoName = 'scoreko';
const checkingUpdates = ref(false);
const updateError = ref('');
const latestRelease = ref<ReleaseResponse | null>(null);
const collaborators = [
{
name: 'Pandipipas',
role: 'Development and maintenance of Scoreko-dev',
url: authorUrl,
icon: 'code',
url: 'https://github.com/Pandipipas/scoreko-dev'
},
{
name: 'Dan Shields',
role: 'nodecg-vue-composable helper',
url: 'https://github.com/Dan-Shields/nodecg-vue-composable',
icon: 'extension',
url: 'https://github.com/Dan-Shields/nodecg-vue-composable'
},
{
name: 'NodeCG',
role: 'Broadcast graphics framework',
url: 'https://github.com/nodecg/nodecg',
icon: 'layers',
},
url: 'https://github.com/nodecg/nodecg'
}
];
const techStack = [
{ label: 'Vue 3', icon: 'hub' },
{ label: 'Quasar', icon: 'style' },
{ label: 'TypeScript', icon: 'data_object' },
{ label: 'NodeCG', icon: 'layers' },
];
const releaseLabel = computed(() => {
if (!latestRelease.value) {
return '';
}
const currentYear = new Date().getFullYear();
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();
});
</script>
<template>
<QPage class="q-pa-lg">
<div class="q-mb-lg">
<div class="text-h5 text-weight-medium">
{{ t('aboutTitle') }}
</div>
<div class="text-h4 q-mb-md">
{{ t('aboutTitle') }}
</div>
<QCard
flat
bordered
class="about-card"
>
<!-- App identity -->
<QCardSection class="q-pa-lg">
<div class="row items-center q-gutter-md">
<QImg
src="../image.png"
alt="Scoreko logo"
class="app-logo"
fit="contain"
/>
<div>
<div class="text-h6 text-weight-bold">
{{ appName }}
<div class="row q-col-gutter-lg">
<div class="col-12 col-md-6">
<QCard
flat
bordered
>
<QCardSection class="row items-center q-col-gutter-md">
<div class="col-auto">
<QImg
src="../image.png"
alt="Scoreko logo"
width="72px"
height="72px"
fit="contain"
/>
</div>
<div class="row items-center q-gutter-xs q-mt-xs">
<QBadge
outline
color="primary"
class="version-badge"
>
v{{ currentVersion }}
</QBadge>
<div class="col">
<div class="text-h6">
{{ appName }}
</div>
<div class="text-caption text-grey-7">
{{ t('aboutVersion') }} {{ currentVersion }}
</div>
</div>
</QCardSection>
<QSeparator />
<QCardSection>
<p class="q-mb-sm">
{{ t('aboutDescription') }}
</p>
<div class="column q-gutter-sm">
<QBtn
:href="`${repoUrl}/releases`"
href="https://github.com/nodecg/nodecg"
target="_blank"
rel="noopener noreferrer"
icon="history"
:label="t('aboutChangelog')"
color="grey-6"
icon="open_in_new"
:label="t('aboutFrameworkNodeCG')"
color="primary"
flat
dense
no-caps
size="xs"
align="left"
/>
</div>
</div>
</div>
</QCardSection>
</QCardSection>
<QSeparator />
<QSeparator />
<!-- Description + framework -->
<QCardSection class="q-pa-lg">
<p class="text-body2 text-grey-7 q-mb-md">
{{ t('aboutDescription') }}
</p>
<QBtn
href="https://github.com/nodecg/nodecg"
target="_blank"
rel="noopener noreferrer"
icon="open_in_new"
:label="t('aboutFrameworkNodeCG')"
color="primary"
flat
dense
no-caps
/>
</QCardSection>
<QSeparator />
<!-- Tech stack -->
<QCardSection class="q-pa-lg">
<div class="text-overline text-grey-6 q-mb-sm">
{{ t('aboutTechStackTitle') }}
</div>
<div class="row q-gutter-xs">
<QChip
v-for="tech in techStack"
:key="tech.label"
:icon="tech.icon"
:label="tech.label"
color="primary"
text-color="white"
size="sm"
dense
/>
</div>
</QCardSection>
<QSeparator />
<!-- Collaborators -->
<QCardSection class="q-pa-lg">
<div class="text-overline text-grey-6 q-mb-sm">
{{ t('aboutCollaboratorsTitle') }}
</div>
<QList
dense
class="collaborators-list"
>
<QItem
v-for="person in collaborators"
:key="person.name"
tag="a"
: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 class="text-weight-medium">
{{ person.name }}
</QItemLabel>
<QItemLabel
caption
class="text-grey-6"
<QCardSection>
<div class="text-subtitle2 q-mb-sm">
{{ t('aboutCollaboratorsTitle') }}
</div>
<QList dense>
<QItem
v-for="person in collaborators"
:key="person.name"
tag="a"
:href="person.url"
target="_blank"
rel="noopener noreferrer"
>
{{ person.role }}
</QItemLabel>
</QItemSection>
<QItemSection side>
<QIcon
name="arrow_forward_ios"
size="12px"
color="grey-5"
/>
</QItemSection>
</QItem>
</QList>
</QCardSection>
<QItemSection>
<QItemLabel>{{ person.name }}</QItemLabel>
<QItemLabel caption>
{{ person.role }}
</QItemLabel>
</QItemSection>
</QItem>
</QList>
</QCardSection>
</QCard>
</div>
<QSeparator />
<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>
<!-- 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
:href="repoUrl"
target="_blank"
rel="noopener noreferrer"
icon="open_in_new"
label="GitHub"
color="grey-6"
flat
dense
no-caps
size="sm"
/>
</div>
</QCardSection>
</QCard>
<QSeparator />
<QCardSection class="q-gutter-md">
<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"
target="_blank"
rel="noopener noreferrer"
no-caps
/>
</template>
</QBanner>
<QBanner
v-if="updateError"
rounded
class="bg-red-1 text-red-10"
>
{{ updateError }}
</QBanner>
<QBanner
rounded
class="bg-blue-1 text-blue-10"
>
{{ t('aboutElectronNote') }}
</QBanner>
</QCardSection>
</QCard>
</div>
</div>
</QPage>
</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,10 +11,9 @@ useHead({ title: 'Dashboard' });
<template>
<QPage class="q-pa-lg">
<div class="dashboard-panels">
<div class="dashboard-panels q-mt-lg">
<div class="dashboard-row dashboard-row--scoreboard">
<QCard
flat
bordered
class="dashboard-panel-card"
>
@@ -26,7 +25,6 @@ useHead({ title: 'Dashboard' });
<div class="dashboard-row dashboard-row--bottom">
<QCard
flat
bordered
class="dashboard-panel-card"
>
@@ -35,7 +33,6 @@ useHead({ title: 'Dashboard' });
</QCardSection>
</QCard>
<QCard
flat
bordered
class="dashboard-panel-card"
>
@@ -56,12 +53,20 @@ 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;
}
@@ -0,0 +1,292 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useGameAssetsStore } from '../stores/game-assets';
const gameAssetsStore = useGameAssetsStore();
const errorMessage = ref('');
const selectedGameSlug = ref<string | null>(null);
const search = ref('');
const getGameLogoUrl = (repoFolder: string, logoFile: string) => {
const cleanBaseUrl = gameAssetsStore.assetsBaseUrl.replace(/\/+$/, '');
const cleanRepoFolder = repoFolder.replace(/^\/+|\/+$/g, '');
const cleanLogoFile = logoFile.replace(/^\/+/, '');
return `${cleanBaseUrl}/games/${cleanRepoFolder}/${cleanLogoFile}`;
};
const normalizedSearch = computed(() => search.value.trim().toLowerCase());
const filteredGames = computed(() => {
if (!normalizedSearch.value) {
return gameAssetsStore.availableGames;
}
return gameAssetsStore.availableGames.filter((game) => game.title.toLowerCase().includes(normalizedSearch.value));
});
const selectedGame = computed(() => gameAssetsStore.availableGames.find((game) => game.slug === selectedGameSlug.value) ?? null);
const installedGameSet = computed(() => new Set(gameAssetsStore.installedGames));
const openGameDialog = (slug: string) => {
selectedGameSlug.value = slug;
};
const closeGameDialog = () => {
selectedGameSlug.value = null;
};
const downloadGameBySlug = async (slug: string) => {
errorMessage.value = '';
try {
await gameAssetsStore.downloadGame(slug);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'No se pudieron descargar los assets.';
}
};
const removeGameBySlug = async (slug: string) => {
errorMessage.value = '';
try {
await gameAssetsStore.removeGame(slug);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'No se pudieron borrar los assets.';
}
};
const onDownloadFromDialog = async () => {
if (!selectedGame.value) {
return;
}
const targetSlug = selectedGame.value.slug;
closeGameDialog();
await downloadGameBySlug(targetSlug);
};
onMounted(async () => {
try {
await gameAssetsStore.refreshInstalledGames();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : 'No se pudo cargar el estado de assets.';
}
});
</script>
<template>
<QPage class="q-pa-lg">
<div class="text-h5 text-weight-bold q-mb-sm">
Biblioteca de juegos
</div>
<p class="text-body2 q-mb-md">
Busca un juego, pulsa información para ver detalles o descarga directamente.
</p>
<QInput
v-model="search"
dense
outlined
clearable
label="Buscar juego"
class="q-mb-lg"
>
<template #prepend>
<QIcon name="search" />
</template>
</QInput>
<div class="game-grid">
<div
v-for="game in filteredGames"
:key="game.slug"
class="game-cell"
>
<div class="logo-tile">
<QImg
:src="getGameLogoUrl(game.repoFolder, game.logoFile)"
fit="contain"
height="74px"
/>
<div
v-if="gameAssetsStore.loadingByTitle[game.slug]"
class="download-overlay"
:style="{ '--progress-width': `${gameAssetsStore.progressByTitle[game.slug] ?? 0}%` }"
/>
<div class="tile-actions">
<QBtn
flat
round
size="md"
icon="info"
color="white"
@click="openGameDialog(game.slug)"
/>
<div class="row items-center no-wrap q-gutter-sm">
<QBtn
v-if="installedGameSet.has(game.slug)"
flat
round
size="md"
icon="check_circle"
color="positive"
disable
/>
<QBtn
v-else
flat
round
size="md"
:icon="gameAssetsStore.loadingByTitle[game.slug] ? 'autorenew' : 'download'"
:class="{ 'downloading-spin': gameAssetsStore.loadingByTitle[game.slug] }"
color="white"
:disable="gameAssetsStore.loadingByTitle[game.slug]"
@click="downloadGameBySlug(game.slug)"
/>
</div>
</div>
</div>
</div>
</div>
<QDialog
:model-value="Boolean(selectedGame)"
@update:model-value="(value) => { if (!value) closeGameDialog(); }"
>
<QCard
v-if="selectedGame"
class="q-pa-md"
style="min-width: 360px; max-width: 480px"
>
<QImg
:src="getGameLogoUrl(selectedGame.repoFolder, selectedGame.logoFile)"
fit="contain"
height="80px"
class="q-mb-md"
/>
<div class="text-h6 text-weight-bold q-mb-sm">
{{ selectedGame.title }}
</div>
<div class="text-body2 q-mb-sm">
Carpeta en servidor: <strong>{{ selectedGame.repoFolder }}</strong>.
</div>
<div class="row q-gutter-sm justify-end">
<QBtn
v-if="installedGameSet.has(selectedGame.slug)"
flat
color="negative"
icon="delete"
:loading="gameAssetsStore.removingByTitle[selectedGame.slug]"
label="Borrar assets"
@click="removeGameBySlug(selectedGame.slug); closeGameDialog()"
/>
<QBtn
color="primary"
icon="download"
:disable="installedGameSet.has(selectedGame.slug)"
:label="installedGameSet.has(selectedGame.slug) ? 'Descargado' : 'Descargar assets'"
@click="onDownloadFromDialog"
/>
</div>
</QCard>
</QDialog>
<QBanner
v-if="errorMessage"
dense
rounded
class="bg-red-2 text-red-10 q-mt-lg"
>
{{ errorMessage }}
</QBanner>
</QPage>
</template>
<style scoped>
.game-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
}
.game-cell {
min-width: 0;
}
.logo-tile {
position: relative;
min-height: 132px;
border: 1px solid #2f3b52;
border-radius: 10px;
padding: 10px;
overflow: hidden;
}
.download-overlay {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: var(--progress-width);
background:
repeating-linear-gradient(
-45deg,
rgba(13, 148, 136, 0.45) 0 12px,
rgba(13, 148, 136, 0.25) 12px 24px
);
background-size: 36px 36px;
animation: shade-slide 1s linear infinite;
pointer-events: none;
z-index: 1;
}
.tile-actions {
position: absolute;
left: 0;
right: 0;
bottom: 4px;
display: flex;
justify-content: center;
align-items: center;
gap: 26px;
z-index: 2;
}
.downloading-spin :deep(.q-icon) {
animation: icon-spin 1s linear infinite;
}
@keyframes shade-slide {
from {
background-position: 0 0;
}
to {
background-position: 36px 0;
}
}
@keyframes icon-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 1400px) {
.game-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 1100px) {
.game-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>
+23 -43
View File
@@ -1,12 +1,13 @@
<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;
@@ -129,29 +130,19 @@ const cards = computed<GraphicCard[]>(() => {
return result;
});
const copiedCardId = ref<string | null>(null);
const copyUrl = async (graphic: GraphicConfig, cardId: string) => {
const copyUrl = async (graphic: GraphicConfig) => {
const url = buildGraphicUrl(graphic);
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url);
} else {
const input = document.createElement('input');
input.value = url;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
return;
}
copiedCardId.value = cardId;
setTimeout(() => {
copiedCardId.value = null;
}, 2000);
};
const openUrl = (graphic: GraphicConfig) => {
window.open(buildGraphicUrl(graphic), '_blank');
const input = document.createElement('input');
input.value = url;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
};
const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
@@ -174,18 +165,16 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<template>
<QPage class="q-pa-lg">
<div class="q-mb-lg">
<div class="text-h5 text-weight-medium">
{{ t('graphicsTitle') }}
</div>
<div class="text-body2 text-grey-7 q-mt-xs">
{{ t('graphicsDescription') }}
</div>
<div class="text-h4 q-mb-md">
{{ t('graphicsTitle') }}
</div>
<div class="text-body1 q-mb-lg">
{{ t('graphicsDescription') }}
</div>
<div
v-if="cards.length === 0"
class="text-body2 text-grey-6"
class="text-body2 text-grey-5"
>
{{ t('graphicsNoConfigured') }}
</div>
@@ -205,7 +194,7 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<div class="text-h6">
{{ card.label }}
</div>
<div class="text-caption text-grey-4">
<div class="text-caption text-grey-5">
{{ card.graphic.file }}
</div>
</div>
@@ -235,31 +224,22 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<div class="row items-center q-gutter-sm">
<QBtn
: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)"
color="primary"
icon="content_copy"
:label="t('graphicsCopyUrl')"
@click="copyUrl(card.graphic)"
/>
<QBtn
color="secondary"
icon="open_with"
no-caps
draggable="true"
:label="t('graphicsDragObs')"
draggable="true"
@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>
</div>
</div>
</QPage>
</template>
</template>
+8 -127
View File
@@ -1,5 +1,8 @@
<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';
@@ -7,8 +10,6 @@ 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;
@@ -524,20 +525,6 @@ 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();
@@ -698,22 +685,6 @@ 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),
@@ -839,14 +810,13 @@ onBeforeUnmount(() => {
<template>
<QPage class="q-pa-lg players-page">
<div class="row items-center q-mb-md">
<div class="text-h5 text-weight-medium">
<div class="text-h4">
{{ t('menuPlayers') }}
</div>
<QSpace />
<QBtn
color="primary"
icon="add"
no-caps
:label="t('playersNewPlayer')"
class="q-ml-sm"
@click="openCreateDialog"
@@ -867,12 +837,10 @@ 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"
/>
@@ -880,7 +848,6 @@ onBeforeUnmount(() => {
color="secondary"
outline
icon="file_download"
no-caps
:label="t('playersExport')"
@click="exportPlayers"
/>
@@ -904,45 +871,6 @@ 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
@@ -980,7 +908,7 @@ onBeforeUnmount(() => {
</svg>
<span>start.gg</span>
</div>
<div class="text-caption text-grey-6 q-mb-md">
<div class="text-caption q-mb-md">
{{ t('playersStartggHelp') }}
</div>
<div class="row q-col-gutter-sm items-center">
@@ -989,7 +917,6 @@ onBeforeUnmount(() => {
v-if="!hasStartGGTokenConfigured"
color="primary"
icon="login"
no-caps
:label="t('playersConnectStartgg')"
:loading="oauthLoading"
@click="connectWithStartGGOAuth"
@@ -999,7 +926,6 @@ onBeforeUnmount(() => {
outline
color="positive"
icon="check_circle"
no-caps
:label="t('playersConnected')"
class="startgg-connected-btn"
@click="openManualTokenDialog"
@@ -1010,7 +936,6 @@ onBeforeUnmount(() => {
outline
color="white"
icon="vpn_key"
no-caps
:label="t('playersUsePersonalApi')"
@click="openManualTokenDialog"
/>
@@ -1095,7 +1020,7 @@ onBeforeUnmount(() => {
>
<span>Challonge</span>
</div>
<div class="text-caption text-grey-6 q-mb-md">
<div class="text-caption q-mb-md">
{{ t('playersChallongeHelp') }}
</div>
<div class="row q-col-gutter-sm items-center">
@@ -1104,7 +1029,6 @@ onBeforeUnmount(() => {
v-if="!hasChallongeTokenConfigured"
color="primary"
icon="login"
no-caps
:label="t('playersConnectChallonge')"
:loading="challongeOauthLoading"
@click="connectWithChallongeOAuth"
@@ -1114,7 +1038,6 @@ onBeforeUnmount(() => {
outline
:color="hasValidatedChallongeToken ? 'positive' : 'warning'"
icon="check_circle"
no-caps
:label="challongeConnectionLabel"
@click="openChallongeManualTokenDialog"
/>
@@ -1124,7 +1047,6 @@ onBeforeUnmount(() => {
outline
color="white"
icon="vpn_key"
no-caps
:label="t('playersUsePersonalApi')"
@click="openChallongeManualTokenDialog"
/>
@@ -1199,6 +1121,7 @@ onBeforeUnmount(() => {
</div>
</div>
<QDialog v-model="isManualTokenDialogOpen">
<QCard class="players-dialog">
<QCardSection>
@@ -1230,20 +1153,17 @@ 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"
@@ -1269,20 +1189,6 @@ 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"
@@ -1297,13 +1203,11 @@ 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"
@@ -1330,20 +1234,6 @@ 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"
@@ -1358,13 +1248,11 @@ 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"
@@ -1398,20 +1286,17 @@ 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"
@@ -1438,8 +1323,6 @@ onBeforeUnmount(() => {
dense
class="players-underlined-field"
autofocus
:rules="[(v) => !!v?.trim() || 'Gamertag is required']"
lazy-rules
/>
</div>
<div class="col-12">
@@ -1493,13 +1376,11 @@ onBeforeUnmount(() => {
<QCardActions align="right">
<QBtn
flat
no-caps
label="Cancel"
color="secondary"
@click="isDialogOpen = false"
/>
<QBtn
no-caps
color="primary"
label="Save"
@click="savePlayer"
@@ -1604,4 +1485,4 @@ onBeforeUnmount(() => {
clip: rect(0, 0, 0, 0);
border: 0;
}
</style>
</style>
+31 -118
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import { useHead } from '@unhead/vue';
import type { Locale } from '../i18n';
import { locale, setLocale, t } from '../i18n';
import {
@@ -11,8 +11,6 @@ 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 },
@@ -28,9 +26,6 @@ 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') },
@@ -38,21 +33,6 @@ 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') {
@@ -61,34 +41,20 @@ const stopRecording = () => {
};
const onRecordKeydown = (event: KeyboardEvent) => {
if (!recordingAction.value) return;
// Escape cancela la grabación sin asignar ningún atajo
if (event.key === 'Escape') {
event.preventDefault();
stopRecording();
if (!recordingAction.value) {
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();
@@ -103,61 +69,55 @@ 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="q-mb-lg">
<div class="text-h5 text-weight-medium">
{{ t('settingsTitle') }}
</div>
<div class="text-body2 text-grey-7 q-mt-xs">
{{ t('settingsDescription') }}
</div>
<div class="text-h4 q-mb-md">
{{ t('settingsTitle') }}
</div>
<div class="text-body1 q-mb-lg">
{{ t('settingsDescription') }}
</div>
<QCard
flat
bordered
class="settings-card"
class="q-pa-md settings-card"
>
<!-- 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.
-->
<QCardSection class="q-pa-none q-mb-lg">
<div class="text-subtitle1 q-mb-sm">
{{ t('settingsLanguageLabel') }}
</div>
<QSelect
v-model="selectedLanguage"
emit-value
map-options
:options="languageOptions"
:label="t('settingsLanguageLabel')"
outlined
dense
:options="languageOptions"
/>
<div class="text-caption text-grey-6 q-mt-sm">
<div class="text-caption text-grey-5 q-mt-sm">
{{ t('settingsLanguageHint') }}
</div>
</QCardSection>
<QSeparator />
<QSeparator class="q-mb-lg" />
<!-- Shortcuts -->
<QCardSection class="q-pa-lg">
<div class="row items-center justify-between q-mb-xs">
<div class="text-overline text-grey-6">
<QCardSection class="q-pa-none">
<div class="row items-center justify-between q-mb-sm">
<div class="text-subtitle1">
{{ t('settingsShortcutTitle') }}
</div>
<QBtn
@@ -173,77 +133,30 @@ onBeforeUnmount(() => {
</QBtn>
</div>
<div class="text-caption text-grey-6 q-mb-lg">
<div class="text-caption text-grey-5 q-mb-md">
{{ t('settingsShortcutDescription') }}
</div>
<!-- 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"
>
<div 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)"
/>
<!-- 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>
<template #hint>
{{ recordingAction === field.action ? t('settingsShortcutRecordingHint') : field.hint }}
</template>
</QInput>
</div>
@@ -254,6 +167,6 @@ onBeforeUnmount(() => {
<style scoped>
.settings-card {
max-width: 600px;
max-width: 720px;
}
</style>
</style>
+513
View File
@@ -0,0 +1,513 @@
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { nodecg } from './util/nodecg.js';
const CHARACTER_NAMES_FILE = 'fighting-characters.json';
const LOCAL_MANIFEST_FILE = 'manifest.json';
const GAME_TITLES_FILE = 'games.json';
const CACHED_GAME_TITLES_FILE = 'games-cache.json';
type RemoteGame = {
title: string;
slug: string;
repoFolder: string;
logoFile: string;
};
type AssetFileEntry = {
path: string;
size: number;
downloadUrl: string;
};
type HttpManifestEntry = string | {
path?: unknown;
size?: unknown;
url?: unknown;
};
type HttpGameTitleEntry = {
slug?: unknown;
title?: unknown;
};
type HttpGameTitlesFile = Record<string, unknown> | HttpGameTitleEntry[];
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const bundleRoot = path.resolve(extensionDir, '..');
const legacyAssetsRoot = path.join(bundleRoot, 'game-assets');
const assetsRoot = path.join(bundleRoot, 'db', `${nodecg.bundleName}-game-assets`);
let assetsStorageReady = false;
const ensureAssetsStorageReady = async () => {
if (assetsStorageReady) {
return;
}
await mkdir(path.dirname(assetsRoot), { recursive: true });
const [currentStats, legacyStats] = await Promise.all([
stat(assetsRoot).catch(() => null),
stat(legacyAssetsRoot).catch(() => null),
]);
if (!currentStats && legacyStats?.isDirectory()) {
await rename(legacyAssetsRoot, assetsRoot).catch(async () => {
await mkdir(assetsRoot, { recursive: true });
});
} else {
await mkdir(assetsRoot, { recursive: true });
}
assetsStorageReady = true;
};
void ensureAssetsStorageReady();
const assetsRouter = nodecg.Router();
assetsRouter.get('/*', async (req, res) => {
const wildcardParam = (req.params as Record<string, unknown>)['0']
?? (req.params as Record<string, unknown>)[''];
const requestedPath = Array.isArray(wildcardParam)
? String(wildcardParam[0] ?? '')
: typeof wildcardParam === 'string'
? wildcardParam
: '';
const normalizedPath = path.normalize(requestedPath).replace(/^(\.\.(?:[\\/]|$))+/, '');
const filePath = path.resolve(assetsRoot, normalizedPath);
if (!filePath.startsWith(assetsRoot)) {
res.status(400).send('Invalid asset path.');
return;
}
try {
const fileStats = await stat(filePath);
if (!fileStats.isFile()) {
res.status(404).send('Asset not found.');
return;
}
res.type(path.extname(filePath));
res.send(await readFile(filePath));
} catch {
res.status(404).send('Asset not found.');
}
});
nodecg.mount(`/bundles/${nodecg.bundleName}/game-assets`, assetsRouter);
const requestHeaders = {
'User-Agent': 'scoreko-dev-nodecg-bundle',
};
const getConfiguredAssetsBaseUrl = () => {
const configuredValue = nodecg.bundleConfig.assetsBaseUrl;
if (typeof configuredValue !== 'string') {
return 'http://localhost';
}
const trimmed = configuredValue.trim();
if (!trimmed) {
return 'http://localhost';
}
return trimmed.replace(/\/+$/, '');
};
const emitProgress = (title: string, progress: number, status: 'downloading' | 'completed' | 'error') => {
nodecg.sendMessage('scoreko-assets:downloadProgress', {
title,
progress: Math.max(0, Math.min(100, progress)),
status,
});
};
const fetchJson = async <T>(url: string): Promise<T> => {
const response = await fetch(url, { headers: requestHeaders });
if (!response.ok) {
throw new Error(`Error HTTP (${response.status}) al solicitar ${url}`);
}
return response.json() as Promise<T>;
};
const normalizeManifestEntry = (entry: HttpManifestEntry, gameTitle: string) => {
if (typeof entry === 'string') {
return {
path: entry,
size: 0,
explicitUrl: null,
};
}
if (typeof entry === 'object' && entry !== null && typeof entry.path === 'string') {
return {
path: entry.path,
size: typeof entry.size === 'number' ? entry.size : 0,
explicitUrl: typeof entry.url === 'string' ? entry.url : null,
};
}
throw new Error(`El ${LOCAL_MANIFEST_FILE} de ${gameTitle} contiene entradas inválidas.`);
};
const titleFromSlug = (slug: string) => slug
.split('-')
.filter(Boolean)
.map((segment) => segment[0].toUpperCase() + segment.slice(1))
.join(' ');
const parseGameTitlesMap = (payload: unknown): Map<string, string> => {
const map = new Map<string, string>();
if (Array.isArray(payload)) {
for (const entry of payload) {
const parsedEntry = entry as HttpGameTitleEntry;
if (
typeof entry === 'object'
&& entry !== null
&& typeof parsedEntry.slug === 'string'
&& typeof parsedEntry.title === 'string'
) {
const slug = parsedEntry.slug.trim();
const title = parsedEntry.title.trim();
if (slug && title) {
map.set(slug, title);
}
}
}
return map;
}
if (typeof payload === 'object' && payload !== null) {
for (const [slug, value] of Object.entries(payload)) {
if (typeof value !== 'string') {
continue;
}
const normalizedSlug = slug.trim();
const title = value.trim();
if (normalizedSlug && title) {
map.set(normalizedSlug, title);
}
}
}
return map;
};
const fetchCustomGameTitles = async (): Promise<Map<string, string>> => {
const baseUrl = getConfiguredAssetsBaseUrl();
const url = `${baseUrl}/games/${GAME_TITLES_FILE}`;
try {
const payload = await fetchJson<HttpGameTitlesFile>(url);
return parseGameTitlesMap(payload);
} catch {
return new Map<string, string>();
}
};
const loadCachedGameTitles = async (): Promise<Map<string, string>> => {
await ensureAssetsStorageReady();
const cachePath = path.join(assetsRoot, CACHED_GAME_TITLES_FILE);
try {
const raw = await readFile(cachePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
return parseGameTitlesMap(parsed);
} catch {
return new Map<string, string>();
}
};
const saveCachedGameTitles = async (titles: Map<string, string>) => {
await ensureAssetsStorageReady();
const cachePath = path.join(assetsRoot, CACHED_GAME_TITLES_FILE);
const payload = Object.fromEntries([...titles.entries()].sort((left, right) => left[0].localeCompare(right[0])));
await writeFile(cachePath, JSON.stringify(payload, null, 2));
};
const listRemoteGames = async (): Promise<RemoteGame[]> => {
const baseUrl = getConfiguredAssetsBaseUrl();
const gamesIndexUrl = `${baseUrl}/games/`;
const customTitles = await fetchCustomGameTitles();
const response = await fetch(gamesIndexUrl, { headers: requestHeaders });
if (!response.ok) {
throw new Error(`Error HTTP (${response.status}) al solicitar ${gamesIndexUrl}`);
}
const html = await response.text();
const hrefMatches = [...html.matchAll(/href=["']([^"']+)["']/gi)].map((match) => match[1]);
const slugs = hrefMatches
.map((href) => {
const withoutQuery = href.split('?')[0]?.split('#')[0] ?? '';
if (!withoutQuery.endsWith('/')) {
return null;
}
const decoded = decodeURIComponent(withoutQuery);
const trimmed = decoded.replace(/^\/+|\/+$/g, '');
if (!trimmed || trimmed.includes('/') || trimmed === '.' || trimmed === '..') {
return null;
}
return trimmed;
})
.filter((slug): slug is string => slug !== null);
const uniqueSlugs = [...new Set(slugs)].sort((left, right) => left.localeCompare(right));
return uniqueSlugs.map((slug) => ({
slug,
repoFolder: slug,
title: customTitles.get(slug) ?? titleFromSlug(slug),
logoFile: `${slug}.png`,
}));
};
const listHttpFiles = async (game: RemoteGame): Promise<AssetFileEntry[]> => {
const baseUrl = getConfiguredAssetsBaseUrl();
const manifestUrl = `${baseUrl}/games/${game.repoFolder}/${LOCAL_MANIFEST_FILE}`;
const entries = await fetchJson<HttpManifestEntry[]>(manifestUrl);
if (!Array.isArray(entries) || entries.length === 0) {
throw new Error(`No se encontraron archivos en ${manifestUrl}.`);
}
return entries.map((rawEntry) => {
const normalized = normalizeManifestEntry(rawEntry, game.title);
const cleanPath = normalized.path.replace(/^\/+/, '');
return {
path: `games/${game.repoFolder}/${cleanPath}`,
size: Math.max(0, normalized.size),
downloadUrl: normalized.explicitUrl ?? `${baseUrl}/games/${game.repoFolder}/${cleanPath}`,
};
});
};
const listInstalledGames = async () => {
await ensureAssetsStorageReady();
const entries = await readdir(assetsRoot, { withFileTypes: true }).catch(() => []);
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort((left, right) => left.localeCompare(right));
};
const listInstalledGamesAsRemote = async (): Promise<RemoteGame[]> => {
const installedGames = await listInstalledGames();
const cachedTitles = await loadCachedGameTitles();
return installedGames.map((slug) => ({
slug,
repoFolder: slug,
title: cachedTitles.get(slug) ?? titleFromSlug(slug),
logoFile: `${slug}.png`,
}));
};
const parseCharacterNames = (content: string, gameTitle: string) => {
const parsed = JSON.parse(content) as unknown;
const names = Array.isArray(parsed)
? parsed
: typeof parsed === 'object' && parsed !== null && Array.isArray((parsed as { characters?: unknown }).characters)
? (parsed as { characters: unknown[] }).characters
: null;
if (!names || names.some((name) => typeof name !== 'string')) {
throw new Error(`El archivo ${CHARACTER_NAMES_FILE} de ${gameTitle} no tiene un formato válido.`);
}
return names;
};
const listInstalledCharacterNamesByGame = async () => {
const installedGames = await listInstalledGames();
const charactersByGame = await Promise.all(installedGames.map(async (slug) => {
const sourcePath = path.join(assetsRoot, slug, CHARACTER_NAMES_FILE);
try {
const fileContent = await readFile(sourcePath, 'utf8');
const names = parseCharacterNames(fileContent, slug);
return [slug, names] as const;
} catch {
return [slug, []] as const;
}
}));
return Object.fromEntries(charactersByGame) as Record<string, string[]>;
};
const downloadGameAssets = async (gameSlug: string) => {
await ensureAssetsStorageReady();
const customTitles = await fetchCustomGameTitles();
const game: RemoteGame = {
slug: gameSlug,
repoFolder: gameSlug,
title: customTitles.get(gameSlug) ?? titleFromSlug(gameSlug),
logoFile: `${gameSlug}.png`,
};
emitProgress(game.slug, 0, 'downloading');
const files = await listHttpFiles(game);
if (!files.length) {
throw new Error(`No se encontraron archivos para ${game.title}.`);
}
const hasCharacterNamesFile = files.some((file) => file.path.endsWith(`/${CHARACTER_NAMES_FILE}`));
if (!hasCharacterNamesFile) {
throw new Error(`No se encontró ${CHARACTER_NAMES_FILE} para ${game.title}.`);
}
const totalBytes = files.reduce((acc, file) => acc + (file.size || 0), 0);
let downloadedBytes = 0;
const destinationFolder = path.join(assetsRoot, game.slug);
await rm(destinationFolder, { recursive: true, force: true });
for (const file of files) {
const relativePath = file.path.replace(`games/${game.repoFolder}/`, '');
const targetPath = path.join(destinationFolder, relativePath);
await mkdir(path.dirname(targetPath), { recursive: true });
const response = await fetch(file.downloadUrl, { headers: requestHeaders });
if (!response.ok) {
throw new Error(`No se pudo descargar ${file.path} (status ${response.status}).`);
}
const arrayBuffer = await response.arrayBuffer();
await writeFile(targetPath, Buffer.from(arrayBuffer));
downloadedBytes += file.size || 0;
const progress = totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 100;
emitProgress(game.slug, progress, 'downloading');
}
emitProgress(game.slug, 100, 'completed');
return {
title: game.title,
slug: game.slug,
};
};
const removeGameAssets = async (gameSlug: string) => {
await ensureAssetsStorageReady();
const customTitles = await fetchCustomGameTitles();
const destinationFolder = path.join(assetsRoot, gameSlug);
await rm(destinationFolder, { recursive: true, force: true });
return {
title: customTitles.get(gameSlug) ?? titleFromSlug(gameSlug),
slug: gameSlug,
};
};
nodecg.listenFor('scoreko-assets:listRemoteGames', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
const remoteGames = await listRemoteGames();
const titlesToCache = new Map<string, string>();
remoteGames.forEach((game) => {
titlesToCache.set(game.slug, game.title);
});
await saveCachedGameTitles(titlesToCache);
ack(null, remoteGames);
} catch (error) {
try {
const installedGames = await listInstalledGamesAsRemote();
if (installedGames.length > 0) {
ack(null, installedGames);
return;
}
} catch {
// noop
}
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:listInstalled', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
ack(null, await listInstalledGames());
} catch (error) {
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:listCharactersByGame', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
ack(null, await listInstalledCharacterNamesByGame());
} catch (error) {
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:getAssetsBaseUrl', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
ack(null, { assetsBaseUrl: getConfiguredAssetsBaseUrl() });
} catch (error) {
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:downloadGame', async (payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
const slug = typeof payload === 'object' && payload !== null ? (payload as { slug?: unknown }).slug : undefined;
if (typeof slug !== 'string') {
throw new Error('Slug de juego inválido.');
}
const downloaded = await downloadGameAssets(slug);
ack(null, {
downloaded,
installedGames: await listInstalledGames(),
});
} catch (error) {
if (typeof payload === 'object' && payload !== null && typeof (payload as { slug?: unknown }).slug === 'string') {
emitProgress((payload as { slug: string }).slug, 0, 'error');
}
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:removeGame', async (payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
const slug = typeof payload === 'object' && payload !== null ? (payload as { slug?: unknown }).slug : undefined;
if (typeof slug !== 'string') {
throw new Error('Slug de juego inválido.');
}
const removed = await removeGameAssets(slug);
ack(null, {
removed,
installedGames: await listInstalledGames(),
});
} catch (error) {
ack((error as Error).message);
}
});
+1
View File
@@ -11,4 +11,5 @@ export default async (nodecg: NodeCGServerAPI) => {
await import('./example.js');
await import('./startgg.js');
await import('./challonge.js');
await import('./game-assets.js');
};
+7 -4
View File
@@ -3,7 +3,7 @@ import { useHead } from '@unhead/vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
import { resolveCountryCode } from '../../shared/countries';
import { getCharactersByGame } from '../../shared/fighting-characters';
import { getCharacterAssetUrl } from '../../shared/fighting-characters';
import type { Schemas } from '../../types';
useHead({ title: 'Scoreboard 2XKO' });
@@ -35,9 +35,12 @@ const rightName = computed(() => scoreboard.value.rightNameOverride || players.v
const leftTeam = computed(() => scoreboard.value.leftTeamOverride);
const rightTeam = computed(() => scoreboard.value.rightTeamOverride);
const charMap = new Map(getCharactersByGame('2XKO').map((char) => [char.value, char.image]));
const leftCharacterImage = computed(() => charMap.get(scoreboard.value.leftCharacter) ?? '');
const rightCharacterImage = computed(() => charMap.get(scoreboard.value.rightCharacter) ?? '');
const leftCharacterImage = computed(() => scoreboard.value.leftCharacter
? getCharacterAssetUrl('2XKO', scoreboard.value.leftCharacter)
: '');
const rightCharacterImage = computed(() => scoreboard.value.rightCharacter
? getCharacterAssetUrl('2XKO', scoreboard.value.rightCharacter)
: '');
const flagModules = import.meta.glob('/node_modules/flag-icons/flags/4x3/*.svg', { import: 'default', query: '?url' }) as Record<string, () => Promise<string>>;
const flagUrlCache: Record<string, string> = {};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

-21
View File
@@ -1,21 +0,0 @@
# Character image catalog
Put custom character images here using this structure:
```text
src/shared/character-images/
street-fighter-6/
ryu.png
ken.png
tekken-8/
jin.png
```
Rules:
- Folder name = game slug (`toLowerCase`, replace non alphanumeric with `-`).
- Example: `Guilty Gear -Strive-` -> `guilty-gear-strive`
- File name = character slug with the same rule.
- Example: `Chun-Li` -> `chun-li`
- Supported extensions: `.png`, `.jpg`, `.jpeg`, `.webp`, `.avif`, `.svg`.
- If an image is missing, the dashboard shows a generated placeholder preview.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Some files were not shown because too many files have changed in this diff Show More