Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20cc81e696 | |||
| 4db5c89f0a | |||
| 752232eeca | |||
| c1e9133970 | |||
| 774bd373d3 | |||
| e922a6061e | |||
| dbbf17c917 | |||
| 097c9014f9 | |||
| 2b2dd3180b | |||
| 69cb280ec1 | |||
| 586c95ec11 | |||
| 98f08e39d3 | |||
| c8097a72d8 | |||
| f91d5eaf48 | |||
| 5e6276ee19 | |||
| d4fe407b92 | |||
| a93492b86b | |||
| 13db5528a8 | |||
| fc82c9215a | |||
| 584f872954 | |||
| 057b5a29c3 | |||
| 1735e38edd | |||
| f8ffad02cb | |||
| d289b7e0b7 | |||
| 27e2e441c0 | |||
| 7ec56575d1 | |||
| d26e0df713 |
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
```
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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,27 +27,13 @@ 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
|
||||
@@ -114,30 +41,9 @@ const rightHandlePreview = computed(() =>
|
||||
icon="swap_horiz"
|
||||
class="commentary-panel__swap-btn"
|
||||
@click="commentaryStore.swapCommentators"
|
||||
>
|
||||
<QTooltip anchor="top middle" self="bottom middle">
|
||||
{{ t('commentarySwap') }}
|
||||
</QTooltip>
|
||||
</QBtn>
|
||||
/>
|
||||
|
||||
<QBtn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="restart_alt"
|
||||
class="commentary-panel__clear-btn"
|
||||
:disable="!isAnythingFilled"
|
||||
@click="clearAll"
|
||||
>
|
||||
<QTooltip anchor="top middle" self="bottom middle">
|
||||
{{ t('commentaryClear') }}
|
||||
</QTooltip>
|
||||
</QBtn>
|
||||
</div>
|
||||
|
||||
<!-- Commentator 2 -->
|
||||
<div class="commentary-panel__commentator">
|
||||
|
||||
<QInput
|
||||
v-model="commentaryStore.rightCommentator"
|
||||
:label="t('commentaryCommentator2')"
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,209 +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',
|
||||
'FATAL FURY: City of the Wolves',
|
||||
'Guilty Gear -Strive-',
|
||||
'Invincible VS',
|
||||
'Mortal Kombat 1',
|
||||
'Street Fighter 6',
|
||||
'TEKKEN 8',
|
||||
'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,444 +0,0 @@
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface IntegrationTournament {
|
||||
id: string | number;
|
||||
name: string;
|
||||
slug: string;
|
||||
startAt: number | null;
|
||||
endAt: number | null;
|
||||
}
|
||||
|
||||
export interface IntegrationPlayer {
|
||||
id: string;
|
||||
gamertag: string;
|
||||
name: string;
|
||||
team: string;
|
||||
country: string;
|
||||
twitter: string;
|
||||
}
|
||||
|
||||
export interface TemporaryPlayerMeta {
|
||||
expiresAt: number;
|
||||
tournamentSlug: string;
|
||||
}
|
||||
|
||||
export type TemporaryPlayersMap = Record<string, TemporaryPlayerMeta>;
|
||||
|
||||
interface TournamentOption {
|
||||
label: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
interface OAuthSessionResponse {
|
||||
sessionId: string;
|
||||
authUrl: string;
|
||||
}
|
||||
|
||||
interface OAuthStatusResponse {
|
||||
status: 'pending' | 'completed' | 'error' | 'expired';
|
||||
token?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PlayersStore {
|
||||
upsertPlayer: (id: string, data: Omit<IntegrationPlayer, 'id'>) => void;
|
||||
removePlayer: (id: string) => void;
|
||||
}
|
||||
|
||||
export interface UseIntegrationOptions {
|
||||
/** Prefijo de los mensajes NodeCG, p.ej. 'startgg' | 'challonge' */
|
||||
messagePrefix: string;
|
||||
/** Nombre legible del proveedor para mensajes de error */
|
||||
providerLabel: string;
|
||||
/** Clave de localStorage para el token */
|
||||
tokenStorageKey: string;
|
||||
/** Clave de localStorage para los jugadores temporales */
|
||||
tempPlayersStorageKey: string;
|
||||
/** Segundos que duran los jugadores temporales si el torneo no tiene endAt */
|
||||
tempFallbackDurationSeconds: number;
|
||||
/** Mensaje de error personalizado cuando la API devuelve 401 */
|
||||
on401Message?: string;
|
||||
/** Store de jugadores */
|
||||
playersStore: PlayersStore;
|
||||
}
|
||||
|
||||
// ─── Utilidad para mensajes NodeCG ─────────────────────────────────────────────
|
||||
|
||||
const sendNodeCGMessage = <T>(messageName: string, payload: unknown): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
nodecg.sendMessage(messageName, payload, (error: unknown, response: unknown) => {
|
||||
if (error) {
|
||||
reject(new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
resolve(response as T);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Composable ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useIntegration(options: UseIntegrationOptions) {
|
||||
const {
|
||||
messagePrefix,
|
||||
providerLabel,
|
||||
tokenStorageKey,
|
||||
tempPlayersStorageKey,
|
||||
tempFallbackDurationSeconds,
|
||||
on401Message,
|
||||
playersStore,
|
||||
} = options;
|
||||
|
||||
// ── Token ───────────────────────────────────────────────────────────────────
|
||||
const token = ref(localStorage.getItem(tokenStorageKey) ?? '');
|
||||
const hasValidatedToken = ref(false);
|
||||
|
||||
watch(token, (value) => {
|
||||
localStorage.setItem(tokenStorageKey, value);
|
||||
hasValidatedToken.value = false;
|
||||
if (!value.trim()) {
|
||||
recentTournaments.value = [];
|
||||
selectedTournamentSlug.value = '';
|
||||
tournamentInput.value = '';
|
||||
tournamentsError.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── Lista de torneos ────────────────────────────────────────────────────────
|
||||
const recentTournaments = ref<IntegrationTournament[]>([]);
|
||||
const loadingTournaments = ref(false);
|
||||
const tournamentsError = ref('');
|
||||
const selectedTournamentSlug = ref('');
|
||||
const tournamentInput = ref('');
|
||||
|
||||
const tournamentOptions = computed<TournamentOption[]>(() =>
|
||||
recentTournaments.value.map((t) => ({
|
||||
label: t.name,
|
||||
value: t.slug,
|
||||
caption: t.slug,
|
||||
})),
|
||||
);
|
||||
|
||||
const filteredTournamentOptions = ref<TournamentOption[]>(tournamentOptions.value);
|
||||
|
||||
watch(tournamentOptions, (value) => {
|
||||
filteredTournamentOptions.value = value;
|
||||
if (
|
||||
selectedTournamentSlug.value &&
|
||||
!recentTournaments.value.some((t) => t.slug === selectedTournamentSlug.value)
|
||||
) {
|
||||
selectedTournamentSlug.value = '';
|
||||
tournamentInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
const filterTournaments = (value: string, update: (cb: () => void) => void) => {
|
||||
update(() => {
|
||||
const needle = value.toLowerCase().trim();
|
||||
filteredTournamentOptions.value = needle
|
||||
? tournamentOptions.value.filter(
|
||||
(o) =>
|
||||
o.label.toLowerCase().includes(needle) ||
|
||||
o.caption.toLowerCase().includes(needle),
|
||||
)
|
||||
: tournamentOptions.value;
|
||||
});
|
||||
};
|
||||
|
||||
const selectedTournamentOption = computed<IntegrationTournament | null>(
|
||||
() => recentTournaments.value.find((t) => t.slug === selectedTournamentSlug.value) ?? null,
|
||||
);
|
||||
|
||||
const canImportSelectedTournament = computed(() => Boolean(selectedTournamentOption.value));
|
||||
const hasTokenConfigured = computed(() => Boolean(token.value.trim()));
|
||||
|
||||
const loadRecentTournaments = async () => {
|
||||
const currentToken = token.value.trim();
|
||||
if (!currentToken) {
|
||||
tournamentsError.value = `Add your ${providerLabel} token to load tournaments.`;
|
||||
recentTournaments.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
tournamentsError.value = '';
|
||||
loadingTournaments.value = true;
|
||||
try {
|
||||
const tournaments = await sendNodeCGMessage<IntegrationTournament[]>(
|
||||
`${messagePrefix}:fetchRecentTournaments`,
|
||||
{ token: currentToken },
|
||||
);
|
||||
hasValidatedToken.value = true;
|
||||
recentTournaments.value = tournaments;
|
||||
if (!tournaments.length) {
|
||||
tournamentsError.value = 'There are no recent tournaments for this account.';
|
||||
}
|
||||
} catch (error) {
|
||||
hasValidatedToken.value = false;
|
||||
const message = error instanceof Error ? error.message : 'Could not load tournaments.';
|
||||
tournamentsError.value =
|
||||
on401Message && message.includes('401') ? on401Message : message;
|
||||
recentTournaments.value = [];
|
||||
} finally {
|
||||
loadingTournaments.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Importación de jugadores ────────────────────────────────────────────────
|
||||
const players = ref<IntegrationPlayer[]>([]);
|
||||
const selectedPlayerIds = ref<string[]>([]);
|
||||
const importDialogOpen = ref(false);
|
||||
const importDialogError = ref('');
|
||||
const loadingPlayers = ref(false);
|
||||
const importingTournament = ref<IntegrationTournament | null>(null);
|
||||
|
||||
const openImportDialog = async (tournament: IntegrationTournament): Promise<void> => {
|
||||
importingTournament.value = tournament;
|
||||
importDialogOpen.value = true;
|
||||
importDialogError.value = '';
|
||||
loadingPlayers.value = true;
|
||||
selectedPlayerIds.value = [];
|
||||
selectedTournamentSlug.value = tournament.slug;
|
||||
tournamentInput.value = tournament.name;
|
||||
players.value = [];
|
||||
|
||||
try {
|
||||
const importedPlayers = await sendNodeCGMessage<IntegrationPlayer[]>(
|
||||
`${messagePrefix}:fetchTournamentPlayers`,
|
||||
{ token: token.value.trim(), slug: tournament.slug },
|
||||
);
|
||||
players.value = importedPlayers;
|
||||
selectedPlayerIds.value = importedPlayers.map((p) => p.id);
|
||||
} catch (error) {
|
||||
importDialogError.value =
|
||||
error instanceof Error ? error.message : 'Could not load players';
|
||||
importDialogOpen.value = false;
|
||||
} finally {
|
||||
loadingPlayers.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openSelectedTournamentImportDialog = () => {
|
||||
if (selectedTournamentOption.value) {
|
||||
void openImportDialog(selectedTournamentOption.value);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAllPlayers = () => {
|
||||
selectedPlayerIds.value =
|
||||
selectedPlayerIds.value.length === players.value.length
|
||||
? []
|
||||
: players.value.map((p) => p.id);
|
||||
};
|
||||
|
||||
const importSelectedPlayers = () => {
|
||||
const selected = players.value.filter((p) => selectedPlayerIds.value.includes(p.id));
|
||||
const tournament = importingTournament.value;
|
||||
const fallbackEndAt =
|
||||
(tournament?.startAt ?? Math.floor(Date.now() / 1000)) + tempFallbackDurationSeconds;
|
||||
const expiresAt = tournament?.endAt ?? fallbackEndAt;
|
||||
const nextMeta = { ...temporaryPlayers.value };
|
||||
|
||||
for (const player of selected) {
|
||||
playersStore.upsertPlayer(player.id, {
|
||||
gamertag: player.gamertag,
|
||||
name: player.name,
|
||||
team: player.team,
|
||||
country: player.country,
|
||||
twitter: player.twitter,
|
||||
});
|
||||
if (tournament) {
|
||||
nextMeta[player.id] = { expiresAt, tournamentSlug: tournament.slug };
|
||||
}
|
||||
}
|
||||
|
||||
temporaryPlayers.value = nextMeta;
|
||||
persistTemporaryPlayers();
|
||||
importDialogOpen.value = false;
|
||||
};
|
||||
|
||||
// ── Jugadores temporales ────────────────────────────────────────────────────
|
||||
const loadTemporaryPlayers = (): TemporaryPlayersMap => {
|
||||
try {
|
||||
const raw = localStorage.getItem(tempPlayersStorageKey);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (typeof parsed !== 'object' || parsed === null) return {};
|
||||
|
||||
const result: TemporaryPlayersMap = {};
|
||||
Object.entries(parsed as Record<string, unknown>).forEach(([playerId, value]) => {
|
||||
if (!playerId || typeof value !== 'object' || value === null) return;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const expiresAt = Number(candidate.expiresAt);
|
||||
const tournamentSlug = String(candidate.tournamentSlug ?? '').trim();
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= 0 || !tournamentSlug) return;
|
||||
result[playerId] = { expiresAt, tournamentSlug };
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const temporaryPlayers = ref<TemporaryPlayersMap>({});
|
||||
|
||||
const persistTemporaryPlayers = () => {
|
||||
localStorage.setItem(tempPlayersStorageKey, JSON.stringify(temporaryPlayers.value));
|
||||
};
|
||||
|
||||
/**
|
||||
* Elimina del store y del mapa los jugadores temporales cuyo expiresAt
|
||||
* ha pasado. Se llama periódicamente en onMounted.
|
||||
*/
|
||||
const cleanupExpiredTemporaryPlayers = () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiredIds = Object.entries(temporaryPlayers.value)
|
||||
.filter(([, meta]) => meta.expiresAt <= now)
|
||||
.map(([id]) => id);
|
||||
|
||||
if (!expiredIds.length) return;
|
||||
|
||||
const nextMeta = { ...temporaryPlayers.value };
|
||||
for (const id of expiredIds) {
|
||||
playersStore.removePlayer(id);
|
||||
delete nextMeta[id];
|
||||
}
|
||||
temporaryPlayers.value = nextMeta;
|
||||
persistTemporaryPlayers();
|
||||
};
|
||||
|
||||
// ── OAuth ───────────────────────────────────────────────────────────────────
|
||||
const oauthLoading = ref(false);
|
||||
const oauthSessionId = ref('');
|
||||
let oauthPollingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const stopPolling = () => {
|
||||
if (oauthPollingTimer) {
|
||||
clearInterval(oauthPollingTimer);
|
||||
oauthPollingTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const checkOAuthStatus = async () => {
|
||||
if (!oauthSessionId.value) return;
|
||||
|
||||
try {
|
||||
const status = await sendNodeCGMessage<OAuthStatusResponse>(
|
||||
`${messagePrefix}:getOAuthSessionStatus`,
|
||||
{ sessionId: oauthSessionId.value },
|
||||
);
|
||||
|
||||
if (status.status === 'completed' && status.token) {
|
||||
token.value = status.token;
|
||||
oauthLoading.value = false;
|
||||
stopPolling();
|
||||
oauthSessionId.value = '';
|
||||
tournamentsError.value = '';
|
||||
await loadRecentTournaments();
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.status === 'error' || status.status === 'expired') {
|
||||
oauthLoading.value = false;
|
||||
stopPolling();
|
||||
oauthSessionId.value = '';
|
||||
tournamentsError.value =
|
||||
status.error ?? `Could not complete OAuth login with ${providerLabel}.`;
|
||||
}
|
||||
} catch (error) {
|
||||
oauthLoading.value = false;
|
||||
stopPolling();
|
||||
oauthSessionId.value = '';
|
||||
tournamentsError.value =
|
||||
error instanceof Error ? error.message : 'Could not verify OAuth status.';
|
||||
}
|
||||
};
|
||||
|
||||
const connectWithOAuth = async () => {
|
||||
oauthLoading.value = true;
|
||||
tournamentsError.value = '';
|
||||
stopPolling();
|
||||
|
||||
try {
|
||||
const session = await sendNodeCGMessage<OAuthSessionResponse>(
|
||||
`${messagePrefix}:createOAuthSession`,
|
||||
{},
|
||||
);
|
||||
oauthSessionId.value = session.sessionId;
|
||||
window.open(session.authUrl, '_blank', 'noopener,noreferrer');
|
||||
|
||||
oauthPollingTimer = setInterval(() => {
|
||||
void checkOAuthStatus();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
oauthLoading.value = false;
|
||||
tournamentsError.value =
|
||||
error instanceof Error ? error.message : `Could not start OAuth with ${providerLabel}.`;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Ciclo de vida ───────────────────────────────────────────────────────────
|
||||
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
temporaryPlayers.value = loadTemporaryPlayers();
|
||||
cleanupExpiredTemporaryPlayers();
|
||||
cleanupTimer = setInterval(cleanupExpiredTemporaryPlayers, 60 * 1000);
|
||||
|
||||
if (token.value.trim()) {
|
||||
void loadRecentTournaments();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPolling();
|
||||
if (cleanupTimer) {
|
||||
clearInterval(cleanupTimer);
|
||||
cleanupTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Retorno como reactive para auto-unwrap en templates ─────────────────────
|
||||
return reactive({
|
||||
// Token
|
||||
token,
|
||||
hasTokenConfigured,
|
||||
hasValidatedToken,
|
||||
|
||||
// Torneos
|
||||
recentTournaments,
|
||||
loadingTournaments,
|
||||
tournamentsError,
|
||||
selectedTournamentSlug,
|
||||
tournamentInput,
|
||||
tournamentOptions,
|
||||
filteredTournamentOptions,
|
||||
selectedTournamentOption,
|
||||
canImportSelectedTournament,
|
||||
filterTournaments,
|
||||
loadRecentTournaments,
|
||||
|
||||
// Importación
|
||||
players,
|
||||
selectedPlayerIds,
|
||||
importDialogOpen,
|
||||
importDialogError,
|
||||
loadingPlayers,
|
||||
importingTournament,
|
||||
openImportDialog,
|
||||
openSelectedTournamentImportDialog,
|
||||
importSelectedPlayers,
|
||||
toggleAllPlayers,
|
||||
|
||||
// Jugadores temporales
|
||||
temporaryPlayers,
|
||||
|
||||
// OAuth
|
||||
oauthLoading,
|
||||
connectWithOAuth,
|
||||
});
|
||||
}
|
||||
|
||||
export type IntegrationHandle = ReturnType<typeof useIntegration>;
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
});
|
||||
@@ -1,105 +1,163 @@
|
||||
<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">
|
||||
<div class="text-h4 q-mb-md">
|
||||
{{ t('aboutTitle') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-col-gutter-lg">
|
||||
<div class="col-12 col-md-6">
|
||||
<QCard
|
||||
flat
|
||||
bordered
|
||||
class="about-card"
|
||||
>
|
||||
<!-- App identity -->
|
||||
<QCardSection class="q-pa-lg">
|
||||
<div class="row items-center q-gutter-md">
|
||||
<QCardSection class="row items-center q-col-gutter-md">
|
||||
<div class="col-auto">
|
||||
<QImg
|
||||
src="../image.png"
|
||||
alt="Scoreko logo"
|
||||
class="app-logo"
|
||||
width="72px"
|
||||
height="72px"
|
||||
fit="contain"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-h6 text-weight-bold">
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="text-h6">
|
||||
{{ appName }}
|
||||
</div>
|
||||
<div class="row items-center q-gutter-xs q-mt-xs">
|
||||
<QBadge
|
||||
outline
|
||||
color="primary"
|
||||
class="version-badge"
|
||||
>
|
||||
v{{ currentVersion }}
|
||||
</QBadge>
|
||||
<QBtn
|
||||
:href="`${repoUrl}/releases`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon="history"
|
||||
:label="t('aboutChangelog')"
|
||||
color="grey-6"
|
||||
flat
|
||||
dense
|
||||
no-caps
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-caption text-grey-7">
|
||||
{{ t('aboutVersion') }} {{ currentVersion }}
|
||||
</div>
|
||||
</div>
|
||||
</QCardSection>
|
||||
|
||||
<QSeparator />
|
||||
|
||||
<!-- Description + framework -->
|
||||
<QCardSection class="q-pa-lg">
|
||||
<p class="text-body2 text-grey-7 q-mb-md">
|
||||
<QCardSection>
|
||||
<p class="q-mb-sm">
|
||||
{{ t('aboutDescription') }}
|
||||
</p>
|
||||
<div class="column q-gutter-sm">
|
||||
<QBtn
|
||||
href="https://github.com/nodecg/nodecg"
|
||||
target="_blank"
|
||||
@@ -108,43 +166,19 @@ const currentYear = new Date().getFullYear();
|
||||
: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
|
||||
align="left"
|
||||
/>
|
||||
</div>
|
||||
</QCardSection>
|
||||
|
||||
<QSeparator />
|
||||
|
||||
<!-- Collaborators -->
|
||||
<QCardSection class="q-pa-lg">
|
||||
<div class="text-overline text-grey-6 q-mb-sm">
|
||||
<QCardSection>
|
||||
<div class="text-subtitle2 q-mb-sm">
|
||||
{{ t('aboutCollaboratorsTitle') }}
|
||||
</div>
|
||||
<QList
|
||||
dense
|
||||
class="collaborators-list"
|
||||
>
|
||||
<QList dense>
|
||||
<QItem
|
||||
v-for="person in collaborators"
|
||||
:key="person.name"
|
||||
@@ -152,101 +186,95 @@ const currentYear = new Date().getFullYear();
|
||||
: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"
|
||||
>
|
||||
<QItemLabel>{{ person.name }}</QItemLabel>
|
||||
<QItemLabel caption>
|
||||
{{ person.role }}
|
||||
</QItemLabel>
|
||||
</QItemSection>
|
||||
<QItemSection side>
|
||||
<QIcon
|
||||
name="arrow_forward_ios"
|
||||
size="12px"
|
||||
color="grey-5"
|
||||
/>
|
||||
</QItemSection>
|
||||
</QItem>
|
||||
</QList>
|
||||
</QCardSection>
|
||||
</QCard>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-md-6">
|
||||
<QCard
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<QCardSection>
|
||||
<div class="text-h6">
|
||||
{{ t('aboutUpdateSystemTitle') }}
|
||||
</div>
|
||||
<div class="text-body2 text-grey-7 q-mt-xs">
|
||||
{{ t('aboutUpdateSystemDescription') }}
|
||||
</div>
|
||||
</QCardSection>
|
||||
|
||||
<QSeparator />
|
||||
|
||||
<!-- 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>
|
||||
<QCardSection class="q-gutter-md">
|
||||
<QBtn
|
||||
:href="repoUrl"
|
||||
: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"
|
||||
icon="open_in_new"
|
||||
label="GitHub"
|
||||
color="grey-6"
|
||||
flat
|
||||
dense
|
||||
no-caps
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
@@ -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 {
|
||||
return;
|
||||
}
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.value = url;
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(input);
|
||||
}
|
||||
|
||||
copiedCardId.value = cardId;
|
||||
setTimeout(() => {
|
||||
copiedCardId.value = null;
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const openUrl = (graphic: GraphicConfig) => {
|
||||
window.open(buildGraphicUrl(graphic), '_blank');
|
||||
};
|
||||
|
||||
const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
|
||||
@@ -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">
|
||||
<div class="text-h4 q-mb-md">
|
||||
{{ t('graphicsTitle') }}
|
||||
</div>
|
||||
<div class="text-body2 text-grey-7 q-mt-xs">
|
||||
<div class="text-body1 q-mb-lg">
|
||||
{{ t('graphicsDescription') }}
|
||||
</div>
|
||||
</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,27 +224,18 @@ 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>
|
||||
|
||||
@@ -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">
|
||||
<div class="text-h4 q-mb-md">
|
||||
{{ t('settingsTitle') }}
|
||||
</div>
|
||||
<div class="text-body2 text-grey-7 q-mt-xs">
|
||||
<div class="text-body1 q-mb-lg">
|
||||
{{ t('settingsDescription') }}
|
||||
</div>
|
||||
</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>
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createServer, type Server, type ServerResponse } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { nodecg } from './util/nodecg.js';
|
||||
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
|
||||
|
||||
// ─── Constantes ────────────────────────────────────────────────────────────────
|
||||
|
||||
const CHALLONGE_API_BASE = 'https://api.challonge.com/v2.1';
|
||||
const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize';
|
||||
@@ -18,9 +17,21 @@ const CHALLONGE_OAUTH_SCOPES = [
|
||||
const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback';
|
||||
const CHALLONGE_OAUTH_DEFAULT_PORT = 34921;
|
||||
const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||
const RECENT_TOURNAMENTS_LIMIT = 20;
|
||||
|
||||
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
||||
interface OAuthConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
callbackPort: number;
|
||||
}
|
||||
|
||||
interface OAuthSession {
|
||||
sessionId: string;
|
||||
state: string;
|
||||
expiresAt: number;
|
||||
status: 'pending' | 'completed' | 'error' | 'expired';
|
||||
token?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
access_token?: string;
|
||||
@@ -46,90 +57,157 @@ interface ImportedPlayer {
|
||||
twitter: string;
|
||||
}
|
||||
|
||||
// ─── Config OAuth ──────────────────────────────────────────────────────────────
|
||||
const oauthSessions = new Map<string, OAuthSession>();
|
||||
let oauthCallbackServer: Server | null = null;
|
||||
|
||||
const getStringProp = (payload: unknown, key: string): string => {
|
||||
if (typeof payload !== 'object' || payload === null || !(key in payload)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const value = (payload as Record<string, unknown>)[key];
|
||||
return typeof value === 'string' ? value.trim() : String(value || '').trim();
|
||||
};
|
||||
|
||||
const getNumberProp = (payload: Record<string, unknown>, keys: string[]): number | null => {
|
||||
for (const key of keys) {
|
||||
const raw = payload[key];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
return raw;
|
||||
}
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||
if (typeof ack !== 'function') {
|
||||
return;
|
||||
}
|
||||
ack(error, response);
|
||||
};
|
||||
|
||||
const getOAuthConfig = (): OAuthConfig | null => {
|
||||
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
|
||||
const clientId = String(bundleConfig.challongeClientId ?? '').trim();
|
||||
const clientSecret = String(bundleConfig.challongeClientSecret ?? '').trim();
|
||||
const clientId = String(bundleConfig.challongeClientId || '').trim();
|
||||
const clientSecret = String(bundleConfig.challongeClientSecret || '').trim();
|
||||
const rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT);
|
||||
const callbackPort =
|
||||
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT;
|
||||
const callbackPort = Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT;
|
||||
|
||||
if (!clientId || !clientSecret) return null;
|
||||
if (!clientId || !clientSecret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { clientId, clientSecret, callbackPort };
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
callbackPort,
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Intercambio de token ──────────────────────────────────────────────────────
|
||||
const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPort}${CHALLONGE_OAUTH_CALLBACK_PATH}`;
|
||||
|
||||
const updateOAuthSession = (sessionId: string, update: Partial<OAuthSession>) => {
|
||||
const session = oauthSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
oauthSessions.set(sessionId, {
|
||||
...session,
|
||||
...update,
|
||||
});
|
||||
};
|
||||
|
||||
const cleanupExpiredOAuthSessions = () => {
|
||||
const now = Date.now();
|
||||
oauthSessions.forEach((session, sessionId) => {
|
||||
if (session.expiresAt <= now && session.status === 'pending') {
|
||||
updateOAuthSession(sessionId, { status: 'expired' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const renderCallbackHtml = (title: string, message: string) => `<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 2rem; background: #121212; color: #fff; }
|
||||
.box { max-width: 680px; padding: 1rem 1.2rem; border: 1px solid #444; border-radius: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h2>${title}</h2>
|
||||
<p>${message}</p>
|
||||
<p>You can close this tab and return to Scoreko.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const respondWithCallbackHtml = (res: ServerResponse, statusCode: number, title: string, message: string) => {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(renderCallbackHtml(title, message));
|
||||
};
|
||||
|
||||
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
|
||||
const rawBody = await response.text();
|
||||
try {
|
||||
return JSON.parse(rawBody) as OAuthTokenResponse;
|
||||
} catch {
|
||||
return { message: rawBody };
|
||||
}
|
||||
};
|
||||
|
||||
const exchangeOAuthCodeForToken = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
config: OAuthConfig,
|
||||
oauthConfig: OAuthConfig,
|
||||
): Promise<string> => {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
client_id: oauthConfig.clientId,
|
||||
client_secret: oauthConfig.clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
let payload: OAuthTokenResponse;
|
||||
try {
|
||||
payload = JSON.parse(rawBody) as OAuthTokenResponse;
|
||||
} catch {
|
||||
payload = { message: rawBody };
|
||||
}
|
||||
const payload = await parseOAuthTokenPayload(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
payload.error_description ??
|
||||
payload.error ??
|
||||
payload.message ??
|
||||
`OAuth token request failed (${response.status})`,
|
||||
);
|
||||
throw new Error(payload.error_description || payload.error || payload.message || `OAuth token request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const token = String(payload.access_token ?? '').trim();
|
||||
const token = String(payload.access_token || '').trim();
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
payload.error_description ??
|
||||
payload.error ??
|
||||
payload.message ??
|
||||
'OAuth token response did not include an access token',
|
||||
);
|
||||
throw new Error(payload.error_description || payload.error || payload.message || 'OAuth token response did not include an access token');
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
// ─── Servidor OAuth ────────────────────────────────────────────────────────────
|
||||
|
||||
const oauthServer = createOAuthServer({
|
||||
provider: 'Challonge',
|
||||
callbackPath: CHALLONGE_OAUTH_CALLBACK_PATH,
|
||||
authorizeEndpoint: CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT,
|
||||
scope: CHALLONGE_OAUTH_SCOPES,
|
||||
sessionTtlMs: CHALLONGE_OAUTH_SESSION_TTL_MS,
|
||||
exchangeToken: exchangeOAuthCodeForToken,
|
||||
});
|
||||
|
||||
// ─── API de Challonge ──────────────────────────────────────────────────────────
|
||||
|
||||
type ChallongeErrorPayload = { errors?: { detail?: string }; error?: string } | null;
|
||||
|
||||
const parseJsonResponse = async (response: Response): Promise<unknown> => {
|
||||
const rawBody = await response.text();
|
||||
if (!rawBody) return null;
|
||||
if (!rawBody) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawBody) as unknown;
|
||||
} catch {
|
||||
@@ -137,21 +215,9 @@ const parseJsonResponse = async (response: Response): Promise<unknown> => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Realiza una petición autenticada a la API de Challonge.
|
||||
*
|
||||
* Intenta primero con OAuth v2 (Bearer token).
|
||||
* Si recibe 401, reintenta con autenticación v1 (API key personal pegada manualmente).
|
||||
* En cualquier otro error no-2xx, lanza inmediatamente.
|
||||
*
|
||||
* CORRECCIÓN: en la versión anterior, el bloque de error final era dead code
|
||||
* porque el body de v2 ya había sido consumido y la condición `!v2Response.ok`
|
||||
* nunca se alcanzaba tras el fallback v1.
|
||||
*/
|
||||
const requestChallonge = async (path: string, token: string): Promise<unknown> => {
|
||||
const requestUrl = `${CHALLONGE_API_BASE}${path}`;
|
||||
|
||||
// ── Intento v2 (OAuth Bearer) ─────────────────────────────────────────────
|
||||
const v2Response = await fetch(requestUrl, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -160,13 +226,14 @@ const requestChallonge = async (path: string, token: string): Promise<unknown> =
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const v2Payload = await parseJsonResponse(v2Response);
|
||||
|
||||
if (v2Response.ok) {
|
||||
return v2Payload;
|
||||
}
|
||||
|
||||
// ── Fallback v1 (API key personal pegada manualmente) ─────────────────────
|
||||
// Fallback for personal API keys pasted manually (v1 auth style).
|
||||
if (v2Response.status === 401) {
|
||||
const v1Response = await fetch(requestUrl, {
|
||||
headers: {
|
||||
@@ -176,68 +243,46 @@ const requestChallonge = async (path: string, token: string): Promise<unknown> =
|
||||
Authorization: token,
|
||||
},
|
||||
});
|
||||
const v1Payload = await parseJsonResponse(v1Response);
|
||||
|
||||
const v1Payload = await parseJsonResponse(v1Response);
|
||||
if (v1Response.ok) {
|
||||
return v1Payload;
|
||||
}
|
||||
}
|
||||
|
||||
const v1Error = v1Payload as ChallongeErrorPayload;
|
||||
const maybeError = v2Payload as { errors?: { detail?: string }; error?: string } | null;
|
||||
if (!v2Response.ok) {
|
||||
throw new Error(
|
||||
v1Error?.errors?.detail ??
|
||||
v1Error?.error ??
|
||||
`Challonge responded with ${v1Response.status} ${v1Response.statusText}`.trim(),
|
||||
maybeError?.errors?.detail || maybeError?.error || `Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Otros errores v2 (4xx/5xx que no sean 401) ────────────────────────────
|
||||
const v2Error = v2Payload as ChallongeErrorPayload;
|
||||
throw new Error(
|
||||
v2Error?.errors?.detail ??
|
||||
v2Error?.error ??
|
||||
`Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(),
|
||||
);
|
||||
return v2Payload;
|
||||
};
|
||||
|
||||
// ─── Parsers de respuesta ──────────────────────────────────────────────────────
|
||||
|
||||
const normalizeTournamentSlug = (value: string): string => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed
|
||||
.replace(/^https?:\/\/[^/]+\//i, '')
|
||||
.replace(/^tournaments\//i, '')
|
||||
.replace(/^\/+/, '');
|
||||
};
|
||||
|
||||
const getNumberProp = (payload: Record<string, unknown>, keys: string[]): number | null => {
|
||||
for (const key of keys) {
|
||||
const raw = payload[key];
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||
if (typeof raw === 'string') {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
if (!trimmed) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return trimmed.replace(/^https?:\/\/[^/]+\//i, '').replace(/^tournaments\//i, '').replace(/^\/+/, '');
|
||||
};
|
||||
|
||||
const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
|
||||
const rows: RecentTournament[] = [];
|
||||
|
||||
const push = (candidate: Record<string, unknown>) => {
|
||||
const attributes =
|
||||
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
||||
const attributes = (typeof candidate.attributes === 'object' && candidate.attributes !== null)
|
||||
? (candidate.attributes as Record<string, unknown>)
|
||||
: candidate;
|
||||
|
||||
const id = String(candidate.id ?? attributes.id ?? attributes.tournament_id ?? '').trim();
|
||||
const name = String(attributes.name ?? attributes.full_name ?? '').trim();
|
||||
const slug = normalizeTournamentSlug(
|
||||
String(attributes.url ?? attributes.slug ?? attributes.identifier ?? id),
|
||||
);
|
||||
const id = String(candidate.id || attributes.id || attributes.tournament_id || '').trim();
|
||||
const name = String(attributes.name || attributes.full_name || '').trim();
|
||||
const slug = normalizeTournamentSlug(String(attributes.url || attributes.slug || attributes.identifier || id));
|
||||
|
||||
if (!id || !name || !slug) return;
|
||||
if (!id || !name || !slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
rows.push({
|
||||
id,
|
||||
@@ -249,25 +294,26 @@ const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
|
||||
};
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
for (const row of payload) {
|
||||
payload.forEach((row) => {
|
||||
const wrapper = row as Record<string, unknown>;
|
||||
const tournament =
|
||||
typeof wrapper.tournament === 'object' && wrapper.tournament !== null
|
||||
const tournament = (typeof wrapper.tournament === 'object' && wrapper.tournament !== null)
|
||||
? (wrapper.tournament as Record<string, unknown>)
|
||||
: wrapper;
|
||||
push(tournament);
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (typeof payload === 'object' && payload !== null) {
|
||||
const data = (payload as Record<string, unknown>).data;
|
||||
const root = payload as Record<string, unknown>;
|
||||
const data = root.data;
|
||||
if (Array.isArray(data)) {
|
||||
for (const row of data) {
|
||||
data.forEach((row) => {
|
||||
if (typeof row === 'object' && row !== null) {
|
||||
push(row as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,127 +324,201 @@ const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
|
||||
const map = new Map<string, ImportedPlayer>();
|
||||
|
||||
const push = (candidate: Record<string, unknown>) => {
|
||||
const attributes =
|
||||
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
||||
const attributes = (typeof candidate.attributes === 'object' && candidate.attributes !== null)
|
||||
? (candidate.attributes as Record<string, unknown>)
|
||||
: candidate;
|
||||
|
||||
const id = String(
|
||||
candidate.id ?? attributes.id ?? attributes.participant_id ?? '',
|
||||
const id = String(candidate.id || attributes.id || attributes.participant_id || '').trim();
|
||||
const gamertag = String(
|
||||
attributes.display_name
|
||||
|| attributes.name
|
||||
|| attributes.username
|
||||
|| attributes.gamer_tag
|
||||
|| '',
|
||||
).trim();
|
||||
|
||||
const rawDisplayName = String(
|
||||
attributes.display_name ??
|
||||
attributes.name ??
|
||||
attributes.username ??
|
||||
attributes.gamer_tag ??
|
||||
'',
|
||||
).trim();
|
||||
if (!id || !gamertag) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!id || !rawDisplayName) return;
|
||||
|
||||
// Detectar patrón "TEAM | Gamertag" o "TEAM |Gamertag" (muy común en fighting games).
|
||||
// Si se detecta, extraer el equipo del propio nombre y limpiar el gamertag.
|
||||
const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/;
|
||||
const pipeMatch = PIPE_PATTERN.exec(rawDisplayName);
|
||||
|
||||
const teamFromName = pipeMatch ? pipeMatch[1].trim() : '';
|
||||
const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName;
|
||||
|
||||
// team_name de la API tiene prioridad; si no existe, usar el extraído del nombre.
|
||||
const team = String(attributes.team_name ?? '').trim() || teamFromName;
|
||||
|
||||
// Challonge no expone un campo de nombre real separado del username/display_name.
|
||||
// Se deja vacío para no duplicar el gamertag en el campo name.
|
||||
map.set(id, {
|
||||
id,
|
||||
gamertag,
|
||||
name: '',
|
||||
team,
|
||||
name: gamertag,
|
||||
team: String(attributes.group_player_ids || attributes.team_name || '').trim(),
|
||||
country: '',
|
||||
twitter: String(attributes.twitter_handle ?? attributes.twitter ?? '').trim(),
|
||||
twitter: String(attributes.twitter_handle || attributes.twitter || '').trim(),
|
||||
});
|
||||
};
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
for (const row of payload) {
|
||||
payload.forEach((row) => {
|
||||
const wrapper = row as Record<string, unknown>;
|
||||
const participant =
|
||||
typeof wrapper.participant === 'object' && wrapper.participant !== null
|
||||
const participant = (typeof wrapper.participant === 'object' && wrapper.participant !== null)
|
||||
? (wrapper.participant as Record<string, unknown>)
|
||||
: wrapper;
|
||||
push(participant);
|
||||
}
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
if (typeof payload === 'object' && payload !== null) {
|
||||
const data = (payload as Record<string, unknown>).data;
|
||||
const root = payload as Record<string, unknown>;
|
||||
const data = root.data;
|
||||
if (Array.isArray(data)) {
|
||||
for (const row of data) {
|
||||
data.forEach((row) => {
|
||||
if (typeof row === 'object' && row !== null) {
|
||||
push(row as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
};
|
||||
|
||||
// ─── Utilidades ────────────────────────────────────────────────────────────────
|
||||
const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => {
|
||||
if (oauthCallbackServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getStringProp = (payload: unknown, key: string): string => {
|
||||
if (typeof payload !== 'object' || payload === null || !(key in payload)) return '';
|
||||
const value = (payload as Record<string, unknown>)[key];
|
||||
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||
const callbackUrl = getCallbackUrl(oauthConfig.callbackPort);
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
if (!req.url) {
|
||||
res.statusCode = 400;
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestUrl = new URL(req.url, callbackUrl);
|
||||
if (requestUrl.pathname !== CHALLONGE_OAUTH_CALLBACK_PATH) {
|
||||
res.statusCode = 404;
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupExpiredOAuthSessions();
|
||||
|
||||
const state = requestUrl.searchParams.get('state') || '';
|
||||
const code = requestUrl.searchParams.get('code') || '';
|
||||
const error = requestUrl.searchParams.get('error') || '';
|
||||
|
||||
const session = Array.from(oauthSessions.values()).find((candidate) => candidate.state === state);
|
||||
if (!session) {
|
||||
respondWithCallbackHtml(res, 400, 'Invalid OAuth', 'No active session was found for this authorization.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.expiresAt <= Date.now()) {
|
||||
updateOAuthSession(session.sessionId, { status: 'expired' });
|
||||
respondWithCallbackHtml(res, 400, 'Session expired', 'The OAuth session expired. Start the process again from Scoreko.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
updateOAuthSession(session.sessionId, { status: 'error', error });
|
||||
respondWithCallbackHtml(res, 400, 'OAuth canceled', `Challonge returned this error: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
updateOAuthSession(session.sessionId, {
|
||||
status: 'error',
|
||||
error: 'Missing authorization code',
|
||||
});
|
||||
respondWithCallbackHtml(res, 400, 'Incomplete OAuth', 'No authorization code was received.');
|
||||
return;
|
||||
}
|
||||
|
||||
void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig)
|
||||
.then((token) => {
|
||||
updateOAuthSession(session.sessionId, { status: 'completed', token, error: undefined });
|
||||
})
|
||||
.catch((exchangeError) => {
|
||||
const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code';
|
||||
updateOAuthSession(session.sessionId, { status: 'error', error: message });
|
||||
});
|
||||
|
||||
respondWithCallbackHtml(res, 200, 'Authorization received', 'Your authorization was received. Finishing sign-in in the background...');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(oauthConfig.callbackPort, '127.0.0.1', () => {
|
||||
server.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
oauthCallbackServer = server;
|
||||
};
|
||||
|
||||
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||
if (typeof ack === 'function') ack(error, response);
|
||||
};
|
||||
|
||||
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
||||
|
||||
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
|
||||
const config = getOAuthConfig();
|
||||
if (!config) {
|
||||
sendAck(
|
||||
ack,
|
||||
'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge OAuth app.',
|
||||
);
|
||||
const oauthConfig = getOAuthConfig();
|
||||
if (!oauthConfig) {
|
||||
sendAck(ack, 'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge OAuth app.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await oauthServer.ensureServer(config);
|
||||
} catch (err) {
|
||||
sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback');
|
||||
await ensureOAuthCallbackServer(oauthConfig);
|
||||
} catch (serverError) {
|
||||
const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback';
|
||||
sendAck(ack, message);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = oauthServer.createSession(config);
|
||||
sendAck(ack, null, session);
|
||||
cleanupExpiredOAuthSessions();
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const state = randomUUID();
|
||||
oauthSessions.set(sessionId, {
|
||||
sessionId,
|
||||
state,
|
||||
expiresAt: Date.now() + CHALLONGE_OAUTH_SESSION_TTL_MS,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: oauthConfig.clientId,
|
||||
redirect_uri: getCallbackUrl(oauthConfig.callbackPort),
|
||||
scope: CHALLONGE_OAUTH_SCOPES,
|
||||
state,
|
||||
});
|
||||
|
||||
sendAck(ack, null, {
|
||||
sessionId,
|
||||
authUrl: `${CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`,
|
||||
});
|
||||
});
|
||||
|
||||
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||
cleanupExpiredOAuthSessions();
|
||||
|
||||
const sessionId = getStringProp(payload, 'sessionId');
|
||||
if (!sessionId) {
|
||||
sendAck(ack, 'Missing OAuth session id');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = oauthServer.getSessionStatus(sessionId);
|
||||
if (!status) {
|
||||
const session = oauthSessions.get(sessionId);
|
||||
if (!session) {
|
||||
sendAck(ack, 'OAuth session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
sendAck(ack, null, status);
|
||||
sendAck(ack, null, {
|
||||
status: session.status,
|
||||
token: session.status === 'completed' ? session.token : undefined,
|
||||
error: session.status === 'error' ? session.error : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
|
||||
if (!token) {
|
||||
sendAck(ack, 'Missing Challonge API token');
|
||||
return;
|
||||
@@ -408,10 +528,12 @@ nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ac
|
||||
const raw = await requestChallonge('/tournaments.json', token);
|
||||
const tournaments = parseRecentTournaments(raw)
|
||||
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
||||
.slice(0, RECENT_TOURNAMENTS_LIMIT);
|
||||
.slice(0, 20);
|
||||
|
||||
sendAck(ack, null, tournaments);
|
||||
} catch (error) {
|
||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
||||
const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments';
|
||||
sendAck(ack, message);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -419,16 +541,22 @@ nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ac
|
||||
const token = getStringProp(payload, 'token');
|
||||
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
|
||||
|
||||
if (!token) { sendAck(ack, 'Missing Challonge API token'); return; }
|
||||
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
||||
if (!token) {
|
||||
sendAck(ack, 'Missing Challonge API token');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
sendAck(ack, 'Missing tournament slug');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await requestChallonge(
|
||||
`/tournaments/${encodeURIComponent(slug)}/participants.json`,
|
||||
token,
|
||||
);
|
||||
sendAck(ack, null, parseImportedPlayers(raw));
|
||||
const raw = await requestChallonge(`/tournaments/${encodeURIComponent(slug)}/participants.json`, token);
|
||||
const players = parseImportedPlayers(raw);
|
||||
sendAck(ack, null, players);
|
||||
} catch (error) {
|
||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
||||
const message = error instanceof Error ? error.message : 'Unknown error while importing players';
|
||||
sendAck(ack, message);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createServer, type Server, type ServerResponse } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getData, type CountryRecord } from 'country-list';
|
||||
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
|
||||
import { nodecg } from './util/nodecg.js';
|
||||
|
||||
// ─── Constantes ────────────────────────────────────────────────────────────────
|
||||
|
||||
const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
|
||||
const STARTGG_OAUTH_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize';
|
||||
const STARTGG_OAUTH_TOKEN_ENDPOINTS = [
|
||||
@@ -18,8 +17,6 @@ const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||
const RECENT_TOURNAMENTS_LIMIT = 12;
|
||||
const PARTICIPANTS_PAGE_SIZE = 120;
|
||||
|
||||
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface StartGGGraphQLResponse<T> {
|
||||
data?: T;
|
||||
errors?: Array<{ message?: string }>;
|
||||
@@ -42,6 +39,21 @@ interface ImportedPlayer {
|
||||
twitter: string;
|
||||
}
|
||||
|
||||
interface OAuthConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
callbackPort: number;
|
||||
}
|
||||
|
||||
interface OAuthSession {
|
||||
sessionId: string;
|
||||
state: string;
|
||||
expiresAt: number;
|
||||
status: 'pending' | 'completed' | 'error' | 'expired';
|
||||
token?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OAuthTokenResponse {
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
@@ -49,98 +61,31 @@ interface OAuthTokenResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ─── Config OAuth ──────────────────────────────────────────────────────────────
|
||||
const oauthSessions = new Map<string, OAuthSession>();
|
||||
let oauthCallbackServer: Server | null = null;
|
||||
|
||||
const getOAuthConfig = (): OAuthConfig | null => {
|
||||
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
|
||||
const clientId = String(bundleConfig.startggClientId ?? '').trim();
|
||||
const clientSecret = String(bundleConfig.startggClientSecret ?? '').trim();
|
||||
const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT);
|
||||
const callbackPort =
|
||||
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT;
|
||||
|
||||
if (!clientId || !clientSecret) return null;
|
||||
|
||||
return { clientId, clientSecret, callbackPort };
|
||||
};
|
||||
|
||||
// ─── Intercambio de token (multi-endpoint) ─────────────────────────────────────
|
||||
|
||||
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
|
||||
const rawBody = await response.text();
|
||||
try {
|
||||
return JSON.parse(rawBody) as OAuthTokenResponse;
|
||||
} catch {
|
||||
return { message: rawBody };
|
||||
}
|
||||
};
|
||||
|
||||
const exchangeOAuthCodeForToken = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
config: OAuthConfig,
|
||||
): Promise<string> => {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
let lastError = 'Unknown OAuth token exchange error';
|
||||
|
||||
for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) {
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const payload = await parseOAuthTokenPayload(response);
|
||||
|
||||
if (response.ok) {
|
||||
const token = String(payload.access_token ?? '').trim();
|
||||
if (token) return token;
|
||||
lastError =
|
||||
payload.error_description ??
|
||||
payload.error ??
|
||||
payload.message ??
|
||||
'OAuth token response did not include an access token';
|
||||
continue;
|
||||
const getStringProp = (payload: unknown, key: string): string => {
|
||||
if (typeof payload !== 'object' || payload === null || !(key in payload)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
lastError =
|
||||
payload.error_description ??
|
||||
payload.error ??
|
||||
payload.message ??
|
||||
`OAuth token request failed (${response.status})`;
|
||||
|
||||
// Solo 404 justifica probar el siguiente endpoint
|
||||
if (response.status !== 404) break;
|
||||
}
|
||||
|
||||
throw new Error(lastError);
|
||||
const value = (payload as Record<string, unknown>)[key];
|
||||
return typeof value === 'string' ? value.trim() : String(value || '').trim();
|
||||
};
|
||||
|
||||
// ─── Servidor OAuth ────────────────────────────────────────────────────────────
|
||||
const updateOAuthSession = (sessionId: string, update: Partial<OAuthSession>) => {
|
||||
const session = oauthSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oauthServer = createOAuthServer({
|
||||
provider: 'start.gg',
|
||||
callbackPath: STARTGG_OAUTH_CALLBACK_PATH,
|
||||
authorizeEndpoint: STARTGG_OAUTH_AUTHORIZE_ENDPOINT,
|
||||
scope: STARTGG_OAUTH_SCOPES,
|
||||
sessionTtlMs: STARTGG_OAUTH_SESSION_TTL_MS,
|
||||
exchangeToken: exchangeOAuthCodeForToken,
|
||||
oauthSessions.set(sessionId, {
|
||||
...session,
|
||||
...update,
|
||||
});
|
||||
};
|
||||
|
||||
// ─── GraphQL ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const requestStartGG = async <T>(
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
token: string,
|
||||
): Promise<T> => {
|
||||
const requestStartGG = async <T>(query: string, variables: Record<string, unknown>, token: string): Promise<T> => {
|
||||
const response = await fetch(STARTGG_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -162,7 +107,7 @@ const requestStartGG = async <T>(
|
||||
}
|
||||
|
||||
if (payload.errors?.length) {
|
||||
throw new Error(payload.errors[0]?.message ?? 'Unknown start.gg error');
|
||||
throw new Error(payload.errors[0]?.message || 'Unknown start.gg error');
|
||||
}
|
||||
|
||||
if (!payload.data) {
|
||||
@@ -172,75 +117,286 @@ const requestStartGG = async <T>(
|
||||
return payload.data;
|
||||
};
|
||||
|
||||
// ─── Resolución de países ──────────────────────────────────────────────────────
|
||||
|
||||
const countries = getData();
|
||||
const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase()));
|
||||
const countryByName = new Map(
|
||||
countries.map((c: CountryRecord) => [c.name.toLowerCase(), c.code.toUpperCase()]),
|
||||
);
|
||||
const countryByCode = new Set(countries.map((country: CountryRecord) => country.code.toUpperCase()));
|
||||
const countryByName = new Map(countries.map((country: CountryRecord) => [country.name.toLowerCase(), country.code.toUpperCase()]));
|
||||
|
||||
const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
|
||||
const raw = (country ?? '').trim();
|
||||
if (!raw) return '';
|
||||
const raw = (country || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const upper = raw.toUpperCase();
|
||||
if (countryByCode.has(upper)) return upper;
|
||||
if (countryByCode.has(upper)) {
|
||||
return upper;
|
||||
}
|
||||
|
||||
return countryByName.get(raw.toLowerCase()) ?? '';
|
||||
};
|
||||
|
||||
// ─── Utilidades ────────────────────────────────────────────────────────────────
|
||||
|
||||
const getStringProp = (payload: unknown, key: string): string => {
|
||||
if (typeof payload !== 'object' || payload === null || !(key in payload)) return '';
|
||||
const value = (payload as Record<string, unknown>)[key];
|
||||
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||
};
|
||||
|
||||
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||
if (typeof ack === 'function') ack(error, response);
|
||||
if (typeof ack !== 'function') {
|
||||
return;
|
||||
}
|
||||
ack(error, response);
|
||||
};
|
||||
|
||||
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
||||
const getOAuthConfig = (): OAuthConfig | null => {
|
||||
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
|
||||
const clientId = String(bundleConfig.startggClientId || '').trim();
|
||||
const clientSecret = String(bundleConfig.startggClientSecret || '').trim();
|
||||
const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT);
|
||||
const callbackPort = Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
clientId,
|
||||
clientSecret,
|
||||
callbackPort,
|
||||
};
|
||||
};
|
||||
|
||||
const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPort}${STARTGG_OAUTH_CALLBACK_PATH}`;
|
||||
|
||||
const cleanupExpiredOAuthSessions = () => {
|
||||
const now = Date.now();
|
||||
oauthSessions.forEach((session, sessionId) => {
|
||||
if (session.expiresAt <= now && session.status === 'pending') {
|
||||
updateOAuthSession(sessionId, { status: 'expired' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const respondWithCallbackHtml = (res: ServerResponse, statusCode: number, title: string, message: string) => {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(renderCallbackHtml(title, message));
|
||||
};
|
||||
|
||||
const renderCallbackHtml = (title: string, message: string) => `<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 2rem; background: #121212; color: #fff; }
|
||||
.box { max-width: 680px; padding: 1rem 1.2rem; border: 1px solid #444; border-radius: 8px; }
|
||||
.ok { color: #66bb6a; }
|
||||
.ko { color: #ef5350; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h2>${title}</h2>
|
||||
<p>${message}</p>
|
||||
<p>You can close this tab and return to Scoreko.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
|
||||
const rawBody = await response.text();
|
||||
try {
|
||||
return JSON.parse(rawBody) as OAuthTokenResponse;
|
||||
} catch {
|
||||
return { message: rawBody };
|
||||
}
|
||||
};
|
||||
|
||||
const exchangeOAuthCodeForToken = async (
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
oauthConfig: OAuthConfig,
|
||||
): Promise<string> => {
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: oauthConfig.clientId,
|
||||
client_secret: oauthConfig.clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
let lastError = 'Unknown OAuth token exchange error';
|
||||
|
||||
for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) {
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const payload = await parseOAuthTokenPayload(response);
|
||||
|
||||
if (response.ok) {
|
||||
const token = String(payload.access_token || '').trim();
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
|
||||
lastError = payload.error_description || payload.error || payload.message || 'OAuth token response did not include an access token';
|
||||
continue;
|
||||
}
|
||||
|
||||
lastError = payload.error_description || payload.error || payload.message || `OAuth token request failed (${response.status})`;
|
||||
|
||||
if (response.status !== 404) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(lastError);
|
||||
};
|
||||
|
||||
const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => {
|
||||
if (oauthCallbackServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbackUrl = getCallbackUrl(oauthConfig.callbackPort);
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
if (!req.url) {
|
||||
res.statusCode = 400;
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestUrl = new URL(req.url, callbackUrl);
|
||||
if (requestUrl.pathname !== STARTGG_OAUTH_CALLBACK_PATH) {
|
||||
res.statusCode = 404;
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupExpiredOAuthSessions();
|
||||
|
||||
const state = requestUrl.searchParams.get('state') || '';
|
||||
const code = requestUrl.searchParams.get('code') || '';
|
||||
const error = requestUrl.searchParams.get('error') || '';
|
||||
|
||||
const session = Array.from(oauthSessions.values()).find((candidate) => candidate.state === state);
|
||||
if (!session) {
|
||||
respondWithCallbackHtml(res, 400, 'Invalid OAuth', 'No active session was found for this authorization.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.expiresAt <= Date.now()) {
|
||||
updateOAuthSession(session.sessionId, { status: 'expired' });
|
||||
respondWithCallbackHtml(res, 400, 'Session expired', 'The OAuth session expired. Start the process again from Scoreko.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
updateOAuthSession(session.sessionId, { status: 'error', error });
|
||||
respondWithCallbackHtml(res, 400, 'OAuth canceled', `start.gg returned this error: ${error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
updateOAuthSession(session.sessionId, {
|
||||
status: 'error',
|
||||
error: 'Missing authorization code',
|
||||
});
|
||||
respondWithCallbackHtml(res, 400, 'Incomplete OAuth', 'No authorization code was received.');
|
||||
return;
|
||||
}
|
||||
|
||||
void exchangeOAuthCodeForToken(code, callbackUrl, oauthConfig)
|
||||
.then((token) => {
|
||||
updateOAuthSession(session.sessionId, { status: 'completed', token, error: undefined });
|
||||
})
|
||||
.catch((exchangeError) => {
|
||||
const message = exchangeError instanceof Error ? exchangeError.message : 'Failed to exchange authorization code';
|
||||
updateOAuthSession(session.sessionId, { status: 'error', error: message });
|
||||
});
|
||||
|
||||
respondWithCallbackHtml(res, 200, 'Authorization received', 'Your authorization was received. Finishing sign-in in the background...');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(oauthConfig.callbackPort, '127.0.0.1', () => {
|
||||
server.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
oauthCallbackServer = server;
|
||||
};
|
||||
|
||||
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
|
||||
const config = getOAuthConfig();
|
||||
if (!config) {
|
||||
sendAck(
|
||||
ack,
|
||||
'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.',
|
||||
);
|
||||
const oauthConfig = getOAuthConfig();
|
||||
if (!oauthConfig) {
|
||||
sendAck(ack, 'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await oauthServer.ensureServer(config);
|
||||
} catch (err) {
|
||||
sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback');
|
||||
await ensureOAuthCallbackServer(oauthConfig);
|
||||
} catch (serverError) {
|
||||
const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback';
|
||||
sendAck(ack, message);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = oauthServer.createSession(config);
|
||||
sendAck(ack, null, session);
|
||||
cleanupExpiredOAuthSessions();
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const state = randomUUID();
|
||||
const session: OAuthSession = {
|
||||
sessionId,
|
||||
state,
|
||||
expiresAt: Date.now() + STARTGG_OAUTH_SESSION_TTL_MS,
|
||||
status: 'pending',
|
||||
};
|
||||
oauthSessions.set(sessionId, session);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: oauthConfig.clientId,
|
||||
redirect_uri: getCallbackUrl(oauthConfig.callbackPort),
|
||||
scope: STARTGG_OAUTH_SCOPES,
|
||||
state,
|
||||
});
|
||||
|
||||
sendAck(ack, null, {
|
||||
sessionId,
|
||||
authUrl: `${STARTGG_OAUTH_AUTHORIZE_ENDPOINT}?${params.toString()}`,
|
||||
});
|
||||
});
|
||||
|
||||
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
|
||||
cleanupExpiredOAuthSessions();
|
||||
|
||||
const sessionId = getStringProp(payload, 'sessionId');
|
||||
|
||||
if (!sessionId) {
|
||||
sendAck(ack, 'Missing OAuth session id');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = oauthServer.getSessionStatus(sessionId);
|
||||
if (!status) {
|
||||
const session = oauthSessions.get(sessionId);
|
||||
if (!session) {
|
||||
sendAck(ack, 'OAuth session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
sendAck(ack, null, status);
|
||||
sendAck(ack, null, {
|
||||
status: session.status,
|
||||
token: session.status === 'completed' ? session.token : undefined,
|
||||
error: session.status === 'error' ? session.error : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
|
||||
const token = getStringProp(payload, 'token');
|
||||
|
||||
if (!token) {
|
||||
sendAck(ack, 'Missing start.gg API token');
|
||||
return;
|
||||
@@ -267,15 +423,21 @@ nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack)
|
||||
currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
|
||||
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
|
||||
|
||||
const tournaments =
|
||||
data.currentUser?.tournaments.nodes
|
||||
const tournaments = data.currentUser?.tournaments.nodes
|
||||
.filter((item) => item.slug)
|
||||
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
||||
.map(({ id, name, slug, startAt, endAt }) => ({ id, name, slug, startAt, endAt })) ?? [];
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
slug: item.slug,
|
||||
startAt: item.startAt,
|
||||
endAt: item.endAt,
|
||||
})) ?? [];
|
||||
|
||||
sendAck(ack, null, tournaments);
|
||||
} catch (error) {
|
||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
||||
const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments';
|
||||
sendAck(ack, message);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -283,8 +445,15 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack)
|
||||
const token = getStringProp(payload, 'token');
|
||||
const slug = getStringProp(payload, 'slug');
|
||||
|
||||
if (!token) { sendAck(ack, 'Missing start.gg API token'); return; }
|
||||
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
||||
if (!token) {
|
||||
sendAck(ack, 'Missing start.gg API token');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
sendAck(ack, 'Missing tournament slug');
|
||||
return;
|
||||
}
|
||||
|
||||
const query = `
|
||||
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
|
||||
@@ -322,37 +491,50 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack)
|
||||
id: number;
|
||||
gamerTag: string | null;
|
||||
prefix: string | null;
|
||||
user: { location: { country: string | null } | null } | null;
|
||||
user: {
|
||||
location: {
|
||||
country: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}>;
|
||||
};
|
||||
} | null;
|
||||
}>(query, { slug, page: currentPage, perPage: PARTICIPANTS_PAGE_SIZE }, token);
|
||||
}>(query, {
|
||||
slug,
|
||||
page: currentPage,
|
||||
perPage: PARTICIPANTS_PAGE_SIZE,
|
||||
}, token);
|
||||
|
||||
if (!data.tournament) throw new Error('Tournament not found');
|
||||
if (!data.tournament) {
|
||||
throw new Error('Tournament not found');
|
||||
}
|
||||
|
||||
const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages);
|
||||
totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1;
|
||||
|
||||
for (const participant of data.tournament.participants.nodes) {
|
||||
data.tournament.participants.nodes.forEach((participant) => {
|
||||
const playerId = String(participant.id);
|
||||
const gamertag = (participant.gamerTag ?? '').trim();
|
||||
if (!gamertag) continue;
|
||||
|
||||
const gamertag = (participant.gamerTag || '').trim();
|
||||
if (!gamertag) {
|
||||
return;
|
||||
}
|
||||
const country = resolveCountryCodeFromStartGG(participant.user?.location?.country);
|
||||
playersMap.set(playerId, {
|
||||
id: playerId,
|
||||
gamertag,
|
||||
name: gamertag,
|
||||
team: (participant.prefix ?? '').trim(),
|
||||
country: resolveCountryCodeFromStartGG(participant.user?.location?.country),
|
||||
team: (participant.prefix || '').trim(),
|
||||
country,
|
||||
twitter: '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
currentPage += 1;
|
||||
}
|
||||
|
||||
sendAck(ack, null, Array.from(playersMap.values()));
|
||||
} catch (error) {
|
||||
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
||||
const message = error instanceof Error ? error.message : 'Unknown error while importing players';
|
||||
sendAck(ack, message);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
import { createServer, type Server, type ServerResponse } from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
// ─── Tipos públicos ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface OAuthConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
callbackPort: number;
|
||||
}
|
||||
|
||||
export interface OAuthSessionStatus {
|
||||
status: 'pending' | 'completed' | 'error' | 'expired';
|
||||
token?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CreateSessionResult {
|
||||
sessionId: string;
|
||||
authUrl: string;
|
||||
}
|
||||
|
||||
export interface OAuthServerOptions {
|
||||
/** Nombre legible del proveedor, usado en mensajes y HTML del callback */
|
||||
provider: string;
|
||||
/** Ruta del callback OAuth, p.ej. '/startgg/callback' */
|
||||
callbackPath: string;
|
||||
/** URL del endpoint de autorización del proveedor */
|
||||
authorizeEndpoint: string;
|
||||
/** Scopes separados por espacio */
|
||||
scope: string;
|
||||
/** Milisegundos antes de que una sesión pendiente expire */
|
||||
sessionTtlMs: number;
|
||||
/**
|
||||
* Intercambia un código de autorización por un access token.
|
||||
* Lanza un error si el intercambio falla.
|
||||
*/
|
||||
exchangeToken: (code: string, redirectUri: string, config: OAuthConfig) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface OAuthServerHandle {
|
||||
/** Arranca el servidor de callback si aún no está corriendo */
|
||||
ensureServer(config: OAuthConfig): Promise<void>;
|
||||
/** Crea una nueva sesión OAuth y devuelve sessionId + URL de autorización */
|
||||
createSession(config: OAuthConfig): CreateSessionResult;
|
||||
/** Devuelve el estado actual de una sesión, o null si no existe */
|
||||
getSessionStatus(sessionId: string): OAuthSessionStatus | null;
|
||||
}
|
||||
|
||||
// ─── Tipos internos ────────────────────────────────────────────────────────────
|
||||
|
||||
interface OAuthSession {
|
||||
sessionId: string;
|
||||
state: string;
|
||||
expiresAt: number;
|
||||
status: 'pending' | 'completed' | 'error' | 'expired';
|
||||
token?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ─── HTML de callback ──────────────────────────────────────────────────────────
|
||||
|
||||
const renderCallbackHtml = (title: string, message: string) => `<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 2rem; background: #121212; color: #fff; }
|
||||
.box { max-width: 680px; padding: 1rem 1.2rem; border: 1px solid #444; border-radius: 8px; }
|
||||
.ok { color: #66bb6a; }
|
||||
.ko { color: #ef5350; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h2>${title}</h2>
|
||||
<p>${message}</p>
|
||||
<p>You can close this tab and return to Scoreko.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const respondWithCallbackHtml = (
|
||||
res: ServerResponse,
|
||||
statusCode: number,
|
||||
title: string,
|
||||
message: string,
|
||||
) => {
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.end(renderCallbackHtml(title, message));
|
||||
};
|
||||
|
||||
// ─── Factory principal ─────────────────────────────────────────────────────────
|
||||
|
||||
export const createOAuthServer = (options: OAuthServerOptions): OAuthServerHandle => {
|
||||
const sessions = new Map<string, OAuthSession>();
|
||||
let server: Server | null = null;
|
||||
|
||||
const getCallbackUrl = (port: number) =>
|
||||
`http://127.0.0.1:${port}${options.callbackPath}`;
|
||||
|
||||
const updateSession = (sessionId: string, update: Partial<OAuthSession>) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
sessions.set(sessionId, { ...session, ...update });
|
||||
};
|
||||
|
||||
/**
|
||||
* Marca como expiradas las sesiones pendientes que han superado su TTL,
|
||||
* y elimina del Map las sesiones ya terminadas (completed / error / expired)
|
||||
* que también hayan superado su TTL.
|
||||
*/
|
||||
const cleanupSessions = () => {
|
||||
const now = Date.now();
|
||||
sessions.forEach((session, sessionId) => {
|
||||
if (session.expiresAt > now) return;
|
||||
|
||||
if (session.status === 'pending') {
|
||||
updateSession(sessionId, { status: 'expired' });
|
||||
}
|
||||
|
||||
// Eliminar sesiones terminadas que ya hayan expirado para no crecer sin límite
|
||||
if (session.status !== 'pending') {
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const ensureServer = async (config: OAuthConfig): Promise<void> => {
|
||||
if (server) return;
|
||||
|
||||
const callbackUrl = getCallbackUrl(config.callbackPort);
|
||||
|
||||
const newServer = createServer((req, res) => {
|
||||
if (!req.url) {
|
||||
res.statusCode = 400;
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
const requestUrl = new URL(req.url, callbackUrl);
|
||||
if (requestUrl.pathname !== options.callbackPath) {
|
||||
res.statusCode = 404;
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupSessions();
|
||||
|
||||
const state = requestUrl.searchParams.get('state') ?? '';
|
||||
const code = requestUrl.searchParams.get('code') ?? '';
|
||||
const error = requestUrl.searchParams.get('error') ?? '';
|
||||
|
||||
const session = Array.from(sessions.values()).find((s) => s.state === state);
|
||||
|
||||
if (!session) {
|
||||
respondWithCallbackHtml(
|
||||
res, 400,
|
||||
'Invalid OAuth',
|
||||
'No active session was found for this authorization.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.expiresAt <= Date.now()) {
|
||||
updateSession(session.sessionId, { status: 'expired' });
|
||||
respondWithCallbackHtml(
|
||||
res, 400,
|
||||
'Session expired',
|
||||
'The OAuth session expired. Start the process again from Scoreko.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
updateSession(session.sessionId, { status: 'error', error });
|
||||
respondWithCallbackHtml(
|
||||
res, 400,
|
||||
'OAuth canceled',
|
||||
`${options.provider} returned this error: ${error}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
updateSession(session.sessionId, { status: 'error', error: 'Missing authorization code' });
|
||||
respondWithCallbackHtml(
|
||||
res, 400,
|
||||
'Incomplete OAuth',
|
||||
'No authorization code was received.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void options
|
||||
.exchangeToken(code, callbackUrl, config)
|
||||
.then((token) => {
|
||||
updateSession(session.sessionId, { status: 'completed', token, error: undefined });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Failed to exchange authorization code';
|
||||
updateSession(session.sessionId, { status: 'error', error: message });
|
||||
});
|
||||
|
||||
respondWithCallbackHtml(
|
||||
res, 200,
|
||||
'Authorization received',
|
||||
'Your authorization was received. Finishing sign-in in the background...',
|
||||
);
|
||||
});
|
||||
|
||||
// Si el servidor sufre un error tras arrancar, resetear la referencia
|
||||
// para que la próxima llamada a ensureServer() pueda reiniciarlo.
|
||||
newServer.on('error', (err) => {
|
||||
console.error(`[${options.provider}] OAuth callback server error:`, err);
|
||||
server = null;
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
newServer.once('error', reject);
|
||||
newServer.listen(config.callbackPort, '127.0.0.1', () => {
|
||||
newServer.off('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
server = newServer;
|
||||
};
|
||||
|
||||
const createSession = (config: OAuthConfig): CreateSessionResult => {
|
||||
cleanupSessions();
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const state = randomUUID();
|
||||
|
||||
sessions.set(sessionId, {
|
||||
sessionId,
|
||||
state,
|
||||
expiresAt: Date.now() + options.sessionTtlMs,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: config.clientId,
|
||||
redirect_uri: getCallbackUrl(config.callbackPort),
|
||||
scope: options.scope,
|
||||
state,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
authUrl: `${options.authorizeEndpoint}?${params.toString()}`,
|
||||
};
|
||||
};
|
||||
|
||||
const getSessionStatus = (sessionId: string): OAuthSessionStatus | null => {
|
||||
cleanupSessions();
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
return {
|
||||
status: session.status,
|
||||
token: session.status === 'completed' ? session.token : undefined,
|
||||
error: session.status === 'error' ? session.error : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
return { ensureServer, createSession, getSessionStatus };
|
||||
};
|
||||
@@ -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> = {};
|
||||
|
||||
|
Before Width: | Height: | Size: 471 KiB |
|
Before Width: | Height: | Size: 448 KiB |
|
Before Width: | Height: | Size: 558 KiB |
|
Before Width: | Height: | Size: 780 KiB |
|
Before Width: | Height: | Size: 371 KiB |
|
Before Width: | Height: | Size: 506 KiB |
|
Before Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 456 KiB |
|
Before Width: | Height: | Size: 500 KiB |
|
Before Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 417 KiB |
|
Before Width: | Height: | Size: 440 KiB |
|
Before Width: | Height: | Size: 322 KiB |
@@ -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.
|
||||
|
Before Width: | Height: | Size: 409 KiB |
|
Before Width: | Height: | Size: 619 KiB |
|
Before Width: | Height: | Size: 456 KiB |
|
Before Width: | Height: | Size: 465 KiB |
|
Before Width: | Height: | Size: 447 KiB |
|
Before Width: | Height: | Size: 407 KiB |
|
Before Width: | Height: | Size: 443 KiB |
|
Before Width: | Height: | Size: 428 KiB |
|
Before Width: | Height: | Size: 435 KiB |
|
Before Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 395 KiB |
|
Before Width: | Height: | Size: 594 KiB |
|
Before Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 424 KiB |
|
Before Width: | Height: | Size: 488 KiB |
|
Before Width: | Height: | Size: 405 KiB |
|
Before Width: | Height: | Size: 674 KiB |
|
Before Width: | Height: | Size: 547 KiB |
|
Before Width: | Height: | Size: 684 KiB |
|
Before Width: | Height: | Size: 599 KiB |
|
Before Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 483 KiB |
|
Before Width: | Height: | Size: 493 KiB |
|
Before Width: | Height: | Size: 679 KiB |
|
Before Width: | Height: | Size: 480 KiB |
|
Before Width: | Height: | Size: 738 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 257 KiB |