28 Commits

Author SHA1 Message Date
Pandipipas d956dfb30b feat(electron): build compressed Windows installer with Electron 40.6.1 2026-03-05 23:41:56 +01:00
Pandipipas 20cc81e696 Merge pull request #153 from Pandipipas/make-asset-downloads-permanent
Make downloaded game assets persistent and usable offline
2026-03-04 16:54:49 +01:00
Pandipipas 4db5c89f0a Cache game titles for offline selector labels 2026-03-04 16:52:16 +01:00
Pandipipas 752232eeca Store persistent assets in bundle-local db directory 2026-03-04 16:45:48 +01:00
Pandipipas c1e9133970 Make downloaded game assets persistent and offline-friendly 2026-03-04 16:10:08 +01:00
Pandipipas 774bd373d3 chore: add pnpm workspace configuration with only built dependencies 2026-03-04 14:50:51 +01:00
Pandipipas e922a6061e feat: remove deprecated character images and README for character catalog 2026-03-03 23:09:12 +01:00
Pandipipas dbbf17c917 Merge pull request #152 from Pandipipas/add-slug-formatting-for-game-names
Soportar nombres visibles de juegos mediante `games/games.json`
2026-03-03 23:04:16 +01:00
Pandipipas 097c9014f9 Mostrar nombre personalizado de juego en selector del dashboard 2026-03-03 21:52:53 +01:00
Pandipipas 2b2dd3180b Soportar nombres de juegos personalizados desde games.json 2026-03-03 21:45:55 +01:00
Pandipipas 69cb280ec1 Merge pull request #151 from Pandipipas/update-gameassetsview-to-auto-load-games
feat: descubrir y mostrar juegos desde el servidor HTTP de assets
2026-03-03 21:22:44 +01:00
Pandipipas 586c95ec11 feat: cargar juegos de assets desde servidor HTTP 2026-03-03 21:06:51 +01:00
Pandipipas 98f08e39d3 Merge pull request #150 from Pandipipas/cache-logos-from-http-server
Use configurable assets base URL for game logos and construct remote asset paths
2026-03-03 20:59:20 +01:00
Pandipipas c8097a72d8 Document how to host remote game logos on HTTP server 2026-03-03 20:59:03 +01:00
Pandipipas f91d5eaf48 Serve game logos directly from assets HTTP server 2026-03-03 20:44:19 +01:00
Pandipipas 5e6276ee19 Merge pull request #149 from Pandipipas/migrate-assets-to-local-server-1rhwip
Switch game assets to HTTP server (remove GitHub API) and require assetsBaseUrl
2026-03-03 20:28:06 +01:00
Pandipipas d4fe407b92 fix: relax bundle config requirements and default assetsBaseUrl 2026-03-03 20:24:56 +01:00
Pandipipas a93492b86b refactor: remove GitHub assets source and require HTTP provider 2026-03-03 20:01:13 +01:00
Pandipipas 13db5528a8 Merge pull request #146 from Pandipipas/add-character-names-download-with-assets
Descargar y usar listas de personajes por juego desde los assets
2026-03-03 16:22:12 +01:00
Pandipipas fc82c9215a Load character names from downloaded game assets 2026-03-03 16:15:11 +01:00
Pandipipas 584f872954 Merge pull request #145 from Pandipipas/add-new-view-for-fighting-games-uyw5fv
Add game assets manager (backend, store, UI) and character image fallbacks
2026-03-03 15:48:24 +01:00
Pandipipas 057b5a29c3 Remove tracked PNG game logos from repository 2026-03-03 15:44:47 +01:00
Pandipipas 1735e38edd Merge pull request #143 from Pandipipas/adapta-bundle-a-nueva-dependencia-nodecg
Bump Node.js engine, simplify start script, and add usage to README
2026-03-03 12:29:50 +01:00
Pandipipas f8ffad02cb Update docs to require Node.js 24.14.0+ 2026-03-03 12:26:58 +01:00
Pandipipas d289b7e0b7 Merge pull request #142 from Pandipipas/node24-deps
Update Node.js version to 24.14.0 in .nvmrc and package.json; adjust …
2026-03-01 15:23:29 +01:00
Pandipipas 27e2e441c0 fixes 2026-03-01 15:21:34 +01:00
Pandipipas 7ec56575d1 Refactor scoreboard settings layout for improved usability; consolidate options into a single sectioned card and enhance input styling. 2026-03-01 15:03:50 +01:00
Pandipipas d26e0df713 Update Node.js version to 24.14.0 in .nvmrc and package.json; adjust TypeScript configurations to extend from @tsconfig/node24. Reorganize dependencies and devDependencies for clarity. 2026-03-01 14:53:16 +01:00
244 changed files with 6213 additions and 5789 deletions
+4 -9
View File
@@ -23,22 +23,17 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 11.0.8
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 24
cache: pnpm cache: npm
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: npm ci
- name: Lint - name: Lint
run: pnpm run lint run: npm run lint
- name: Build - name: Build
run: pnpm run build run: npm run build
+4 -11
View File
@@ -1,7 +1,7 @@
# Logs # Logs
logs logs
*.log *.log
pnpm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* lerna-debug.log*
@@ -48,11 +48,8 @@ web_modules/
# TypeScript cache # TypeScript cache
*.tsbuildinfo *.tsbuildinfo
# Optional pnpm cache directory # Optional npm cache directory
.pnpm-store .npm
.corepack/
.npm-cache/
.node-gyp/
# Optional eslint cache # Optional eslint cache
.eslintcache .eslintcache
@@ -69,7 +66,7 @@ web_modules/
# Optional REPL history # Optional REPL history
.node_repl_history .node_repl_history
# Output of 'pnpm pack' # Output of 'npm pack'
*.tgz *.tgz
# Yarn Integrity file # Yarn Integrity file
@@ -137,8 +134,4 @@ dist
/extension/ /extension/
/graphics/ /graphics/
/shared/dist/ /shared/dist/
# Local runtime database
/db/ /db/
*.sqlite3
/scoreko-electron-dev/
+1
View File
@@ -0,0 +1 @@
24.14.0
+103 -8
View File
@@ -9,18 +9,113 @@ NodeCG bundle for producing fighting game overlays.
## Requirements ## Requirements
- Node.js 22+ - Node.js 24.14.0+
- NodeCG 2.3+
## Scripts ## Scripts
- `pnpm run autofix`: automatically fixes lint errors. - `npm run autofix`: automatically fixes lint errors.
- `pnpm run build`: builds dashboard/graphics and extension. - `npm run build`: builds dashboard/graphics and extension.
- `pnpm run lint`: validates project linting. - `npm run lint`: validates project linting.
- `pnpm run schema-types`: generates types from schemas. - `npm run schema-types`: generates types from schemas.
- `pnpm run start`: starts NodeCG. - `npm run start`: starts NodeCG using the local dependency (`nodecg start`).
- `pnpm run watch`: development mode with watch. - `npm run watch`: development mode with watch.
## Usage
- `npm install`
- `npm run build`
- `npm run start` (equivalent to `npx nodecg start`)
## Version ## Version
Initial project version: `0.1.0`. 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"
]
```
+19 -9
View File
@@ -3,39 +3,49 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"oauthProxyUrl": { "exampleProperty": {
"type": "string", "type": "string",
"description": "Sobreescribe la URL base del proxy OAuth (por defecto usa la constante del código). Útil para staging o desarrollo del proxy." "default": ""
}, },
"startggClientId": { "startggClientId": {
"type": "string", "type": "string",
"description": "DEV ONLY: Client ID de tu propia OAuth app de start.gg. Si está presente junto a startggClientSecret, activa el modo dev (exchange directo, sin proxy)." "default": "",
"description": "Client ID de tu OAuth app de start.gg"
}, },
"startggClientSecret": { "startggClientSecret": {
"type": "string", "type": "string",
"description": "DEV ONLY: Client Secret de tu propia OAuth app de start.gg. NUNCA subas este valor a git." "default": "",
"description": "Client Secret de tu OAuth app de start.gg"
}, },
"startggOAuthPort": { "startggOAuthPort": {
"type": "integer", "type": "integer",
"default": 34920, "default": 34920,
"minimum": 1, "minimum": 1,
"maximum": 65535, "maximum": 65535,
"description": "Puerto local para el servidor de callback OAuth de start.gg." "description": "Puerto local para callback OAuth"
}, },
"challongeClientId": { "challongeClientId": {
"type": "string", "type": "string",
"description": "DEV ONLY: Client ID de tu propia OAuth app de Challonge. Si está presente junto a challongeClientSecret, activa el modo dev." "default": "",
"description": "Client ID de tu OAuth app de Challonge"
}, },
"challongeClientSecret": { "challongeClientSecret": {
"type": "string", "type": "string",
"description": "DEV ONLY: Client Secret de tu propia OAuth app de Challonge. NUNCA subas este valor a git." "default": "",
"description": "Client Secret de tu OAuth app de Challonge"
}, },
"challongeOAuthPort": { "challongeOAuthPort": {
"type": "integer", "type": "integer",
"default": 34921, "default": 34921,
"minimum": 1, "minimum": 1,
"maximum": 65535, "maximum": 65535,
"description": "Puerto local para el servidor de callback OAuth de Challonge." "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": []
} }
+49
View File
@@ -0,0 +1,49 @@
# Electron wrapper (Windows)
Este wrapper crea una app de escritorio para Windows que lanza NodeCG sin requerir que el usuario final tenga Node.js instalado.
## Requisitos de build (solo para quien genera el instalador)
1. Instalar dependencias del bundle raíz:
```bash
pnpm install
```
2. Instalar dependencias del wrapper:
```bash
cd electron
pnpm install
```
## Desarrollo local
Desde `electron/`:
```bash
pnpm start
```
## Generar instalador `.exe` (comprimido)
Desde `electron/`:
```bash
pnpm dist:win
```
Esto genera un instalador NSIS en `electron/dist/` con compresión máxima (`compression: maximum`).
## Qué incluye el instalador
- Runtime de Electron (incluye Node embebido).
- Dependencia `nodecg` dentro de la app.
- El bundle `scoreko-dev` como recurso (`resources/bundle`).
Con eso, el usuario final instala y ejecuta la app sin instalar Node.js aparte.
## Variables opcionales
- `NODECG_PORT` (por defecto `9090`)
- `NODECG_HOST` (por defecto `127.0.0.1`)
+118
View File
@@ -0,0 +1,118 @@
import { app, BrowserWindow } from 'electron';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const BUNDLE_ROOT = app.isPackaged
? path.join(process.resourcesPath, 'bundle')
: path.resolve(__dirname, '..');
const NODECG_PORT = Number.parseInt(process.env.NODECG_PORT ?? '9090', 10);
const NODECG_HOST = process.env.NODECG_HOST ?? '127.0.0.1';
const NODECG_URL = `http://${NODECG_HOST}:${NODECG_PORT}`;
let nodecgProcess;
function getNodecgCliPath() {
return app.isPackaged
? path.join(process.resourcesPath, 'app.asar.unpacked', 'node_modules', 'nodecg', 'bin', 'nodecg.js')
: path.join(__dirname, 'node_modules', 'nodecg', 'bin', 'nodecg.js');
}
function startNodecg() {
const nodecgCli = getNodecgCliPath();
nodecgProcess = spawn(process.execPath, [nodecgCli, 'start'], {
cwd: BUNDLE_ROOT,
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
NODECG_PORT: String(NODECG_PORT),
},
stdio: 'inherit',
});
nodecgProcess.on('exit', (code) => {
if (code !== 0) {
console.error(`[electron] NodeCG process exited with code ${code}`);
}
if (!app.isQuitting) {
app.quit();
}
});
}
function stopNodecg() {
if (!nodecgProcess || nodecgProcess.killed) {
return;
}
app.isQuitting = true;
nodecgProcess.kill();
}
async function waitForNodecg(retries = 80, delayMs = 250) {
for (let attempt = 0; attempt < retries; attempt += 1) {
try {
const response = await fetch(NODECG_URL, { method: 'HEAD' });
if (response.ok || response.status >= 300) {
return;
}
} catch {
// Ignore connection errors while NodeCG boots.
}
await new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
}
throw new Error(`NodeCG did not start in time at ${NODECG_URL}`);
}
async function createWindow() {
const mainWindow = new BrowserWindow({
width: 1366,
height: 768,
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
sandbox: true,
},
});
await mainWindow.loadURL(NODECG_URL);
}
app.whenReady().then(async () => {
startNodecg();
try {
await waitForNodecg();
await createWindow();
} catch (error) {
console.error('[electron] Failed to start wrapper:', error);
app.quit();
}
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
await createWindow();
}
});
});
app.on('before-quit', () => {
app.isQuitting = true;
stopNodecg();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
+69
View File
@@ -0,0 +1,69 @@
{
"name": "scoreko-dev-electron-wrapper",
"version": "0.2.0",
"private": true,
"description": "Electron wrapper for running the scoreko-dev NodeCG bundle on Windows.",
"main": "main.mjs",
"type": "module",
"scripts": {
"start": "electron .",
"dist:win": "electron-builder --win nsis"
},
"dependencies": {
"electron": "40.6.1",
"nodecg": "^2.6.4"
},
"devDependencies": {
"electron-builder": "^26.0.12"
},
"build": {
"appId": "com.scoreko.dev",
"productName": "Scoreko Dev",
"compression": "maximum",
"asar": true,
"directories": {
"output": "dist"
},
"files": [
"main.mjs",
"package.json",
"node_modules/**/*"
],
"extraResources": [
{
"from": "../",
"to": "bundle",
"filter": [
"package.json",
"configschema.json",
"LICENSE",
"README.md",
"schemas/**/*",
"dashboard/**/*",
"graphics/**/*",
"extension/**/*",
"src/**/*",
"node_modules/**/*",
"!electron/**/*",
"!.git/**/*"
]
}
],
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
}
}
+37 -38
View File
@@ -12,52 +12,23 @@
}, },
"license": "MIT", "license": "MIT",
"author": "Pandipipas", "author": "Pandipipas",
"packageManager": "pnpm@11.0.8",
"type": "module", "type": "module",
"engines": {
"node": ">=24.14.0"
},
"scripts": { "scripts": {
"autofix": "eslint --fix", "autofix": "eslint --fix",
"prebuild": "trash ./extension && trash ./node_modules/.vite && trash ./shared/dist && trash ./dashboard && trash ./graphics", "prebuild": "trash ./extension && trash ./node_modules/.vite && trash ./shared/dist && trash ./dashboard && trash ./graphics",
"build": "vite build && tsc -b tsconfig.extension.json", "build": "vite build && tsc -b tsconfig.extension.json",
"lint": "eslint", "lint": "eslint",
"schema-types": "nodecg schema-types", "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\"" "watch": "conc -n B,E -c red,blue -k vite \"tsc -b -w --preserveWatchOutput tsconfig.extension.json\"",
}, "electron:start": "pnpm --dir electron start",
"devDependencies": { "electron:dist": "pnpm --dir electron dist:win"
"@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": { "nodecg": {
"compatibleRange": "^2.3.0", "compatibleRange": "^2.6.0",
"dashboardPanels": [ "dashboardPanels": [
{ {
"name": "scoreko-dev", "name": "scoreko-dev",
@@ -102,7 +73,35 @@
} }
}, },
"dependencies": { "dependencies": {
"@quasar/extras": "^1.17.0",
"@unhead/vue": "^2.0.19",
"country-list": "^2.4.1", "country-list": "^2.4.1",
"flag-icons": "^7.5.0" "flag-icons": "^7.5.0",
"nodecg": "^2.6.4",
"nodecg-vue-composable": "^1.1.0",
"pinia": "^2.3.1",
"quasar": "^2.18.5",
"vue": "^3.5.22",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.0",
"@quasar/vite-plugin": "^1.10.0",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^22.18.13",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.8.1",
"concurrently": "^9.2.1",
"eslint": "^9.39.0",
"eslint-plugin-vue": "^10.5.1",
"sass-embedded": "^1.93.3",
"trash-cli": "^7.0.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2",
"vite": "^7.1.12",
"vite-plugin-checker": "^0.11.0",
"vite-plugin-nodecg": "^2.1.0",
"vue-tsc": "^3.1.2"
} }
} }
+1009 -1045
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,7 +1,7 @@
allowBuilds: onlyBuiltDependencies:
'@parcel/watcher': true - '@parcel/watcher'
'@vaadin/vaadin-usage-statistics': true - '@vaadin/vaadin-usage-statistics'
better-sqlite3: true - better-sqlite3
esbuild: true - esbuild
msgpackr-extract: true - msgpackr-extract
vue-demi: true - vue-demi
+38
View File
@@ -2,6 +2,35 @@
import { ref } from 'vue'; import { ref } from 'vue';
const loadQuotes = [ const loadQuotes = [
// Misc
'Demanding rollback netcode',
'Disrespecting your plus frames',
'Taking your lunch money',
// Street Fighter
'Parrying your super',
'Fighting like gentlemen',
'Fighting a new rival',
'Keeping it classy',
"Protecting Russia's skies",
'Waking up with Dragon Punch',
'Teching those throws',
'Finding the heart of battle',
'Chucking plasma',
'Executing the Yeah Nah Yeah',
// Guilty Gear
'Counter-hitting Pilebunker',
'Riding the lightning',
'Knowing the smell of the game',
'Dropping the instant kill combo',
'What are you standing up for?!',
'Stealing your soul',
'Channelling your inner gorilla',
'Initiating danger time',
'Dragon Installing',
'Practising dust loops',
// BlazBlue
'Turning the wheel of fate',
'Escaping from crossing fate',
// Tekken // Tekken
"Complaining about Paul's damage", "Complaining about Paul's damage",
'Nerfing Gigas', 'Nerfing Gigas',
@@ -9,6 +38,15 @@ const loadQuotes = [
'Sidestepping your electric', 'Sidestepping your electric',
'Punishing hellsweep with 1,1,2', 'Punishing hellsweep with 1,1,2',
'Emailing Harada', 'Emailing Harada',
// Marvel
'Explaining the DHC glitch',
"When's Mahvel?",
'Thanking god for the machine',
'Setting up shop',
'Getting motivated',
'Activating X-Factor',
// Dragon Ball
'Adding yet another Goku',
]; ];
const randomIndex = Math.floor(Math.random() * loadQuotes.length); const randomIndex = Math.floor(Math.random() * loadQuotes.length);
@@ -1,12 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
import { t } from '../i18n'; import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../stores/scoreboard';
const scoreboardStore = useScoreboardStore(); const scoreboardStore = useScoreboardStore();
let customDeactivateTimer: ReturnType<typeof setTimeout> | null = null;
const stageOptions = [ const stageOptions = [
'pools', 'pools',
'top 128', 'top 128',
@@ -27,7 +25,7 @@ const stageOptions = [
const bracketSideOptions = [ const bracketSideOptions = [
{ label: 'None', value: '' }, { label: 'None', value: '' },
{ label: 'Winners', value: 'Winners' }, { label: 'Winners', value: 'Winners' },
{ label: 'Losers', value: 'Losers' }, { label: 'Loosers', value: 'Loosers' },
]; ];
const stage = ref(stageOptions[0]); const stage = ref(stageOptions[0]);
@@ -36,14 +34,6 @@ const customActive = ref(false);
const customText = ref(''); const customText = ref('');
const hasChanges = ref(false); const hasChanges = ref(false);
const previewText = computed(() => {
if (customActive.value) {
return customText.value.trim() || '—';
}
const prefix = bracketSide.value ? `${bracketSide.value} ` : '';
return `${prefix}${stage.value}`.trim() || '—';
});
const parseInitialRound = () => { const parseInitialRound = () => {
const round = scoreboardStore.scoreboard.round.trim(); const round = scoreboardStore.scoreboard.round.trim();
if (!round) { if (!round) {
@@ -100,14 +90,8 @@ watch(customActive, (value) => {
}); });
watch(customText, (value) => { watch(customText, (value) => {
if (customDeactivateTimer) {
clearTimeout(customDeactivateTimer);
}
if (!value.trim()) { if (!value.trim()) {
customDeactivateTimer = setTimeout(() => { customActive.value = false;
customActive.value = false;
customDeactivateTimer = null;
}, 600);
} }
}); });
@@ -128,7 +112,6 @@ onMounted(() => {
v-model="stage" v-model="stage"
:label="t('bracketStage')" :label="t('bracketStage')"
:options="stageOptions" :options="stageOptions"
:disable="customActive"
dense dense
class="bracket-panel__field" class="bracket-panel__field"
/> />
@@ -136,7 +119,6 @@ onMounted(() => {
v-model="bracketSide" v-model="bracketSide"
:label="t('bracketSide')" :label="t('bracketSide')"
:options="bracketSideOptions" :options="bracketSideOptions"
:disable="customActive"
dense dense
emit-value emit-value
map-options map-options
@@ -147,7 +129,6 @@ onMounted(() => {
v-model="customText" v-model="customText"
:label="t('bracketCustomProgress')" :label="t('bracketCustomProgress')"
dense dense
clearable
class="bracket-panel-custom-input bracket-panel__field" class="bracket-panel-custom-input bracket-panel__field"
/> />
<QToggle <QToggle
@@ -157,17 +138,6 @@ onMounted(() => {
class="bracket-panel-custom-toggle" class="bracket-panel-custom-toggle"
/> />
</div> </div>
<!-- Preview -->
<div class="bracket-panel__preview">
<span class="bracket-panel__preview-label">{{ t('bracketPreview') }}</span>
<span
class="bracket-panel__preview-text"
:class="{ 'bracket-panel__preview-text--custom': customActive }"
>
{{ previewText }}
</span>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -213,36 +183,4 @@ onMounted(() => {
.bracket-panel__field :deep(.q-field__label) { .bracket-panel__field :deep(.q-field__label) {
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} }
.bracket-panel__preview {
display: flex;
align-items: baseline;
gap: 10px;
padding: 8px 10px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
}
.bracket-panel__preview-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.45);
white-space: nowrap;
flex-shrink: 0;
}
.bracket-panel__preview-text {
font-size: 1rem;
font-weight: 600;
color: rgba(255, 255, 255, 0.92);
letter-spacing: 0.02em;
word-break: break-word;
transition: color 0.2s ease;
}
.bracket-panel__preview-text--custom {
color: var(--q-secondary, #26a69a);
}
</style> </style>
@@ -1,65 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { t } from '../i18n'; import { t } from '../i18n';
import { useCommentaryStore } from '../stores/commentary'; import { useCommentaryStore } from '../stores/commentary';
const commentaryStore = useCommentaryStore(); const commentaryStore = useCommentaryStore();
// --- Twitter handle helpers ---
const TWITTER_MAX_LENGTH = 15;
const TWITTER_VALID_CHARS = /^[A-Za-z0-9_]*$/;
const twitterRules = [
(val: string) =>
!val || val.length <= TWITTER_MAX_LENGTH || t('commentaryTwitterMaxLength'),
(val: string) =>
!val || TWITTER_VALID_CHARS.test(val) || t('commentaryTwitterInvalidChars'),
];
function stripAt(value: string): string {
return value.startsWith('@') ? value.slice(1) : value;
}
function handleLeftTwitterInput(value: string | number | null) {
commentaryStore.leftCommentatorTwitter = value ? stripAt(String(value)) : '';
}
function handleRightTwitterInput(value: string | number | null) {
commentaryStore.rightCommentatorTwitter = value ? stripAt(String(value)) : '';
}
// --- Clear ---
function clearAll() {
commentaryStore.leftCommentator = '';
commentaryStore.leftCommentatorTwitter = '';
commentaryStore.rightCommentator = '';
commentaryStore.rightCommentatorTwitter = '';
}
const isAnythingFilled = computed(() =>
!!(
commentaryStore.leftCommentator ||
commentaryStore.leftCommentatorTwitter ||
commentaryStore.rightCommentator ||
commentaryStore.rightCommentatorTwitter
)
);
// --- Handle preview ---
const leftHandlePreview = computed(() =>
commentaryStore.leftCommentatorTwitter
? `@${commentaryStore.leftCommentatorTwitter}`
: ''
);
const rightHandlePreview = computed(() =>
commentaryStore.rightCommentatorTwitter
? `@${commentaryStore.rightCommentatorTwitter}`
: ''
);
</script> </script>
<template> <template>
@@ -71,9 +14,7 @@ const rightHandlePreview = computed(() =>
</div> </div>
<div class="commentary-panel__layout"> <div class="commentary-panel__layout">
<!-- Commentator 1 -->
<div class="commentary-panel__commentator"> <div class="commentary-panel__commentator">
<QInput <QInput
v-model="commentaryStore.leftCommentator" v-model="commentaryStore.leftCommentator"
:label="t('commentaryCommentator1')" :label="t('commentaryCommentator1')"
@@ -86,58 +27,23 @@ const rightHandlePreview = computed(() =>
</QInput> </QInput>
<QInput <QInput
:model-value="commentaryStore.leftCommentatorTwitter" v-model="commentaryStore.leftCommentatorTwitter"
:label="t('commentaryTwitterText')" :label="t('commentaryTwitterText')"
:rules="twitterRules"
:maxlength="TWITTER_MAX_LENGTH"
dense dense
class="commentary-panel__field" class="commentary-panel__field"
@update:model-value="handleLeftTwitterInput"
/> />
<Transition name="commentary-panel__preview">
<div
v-if="leftHandlePreview"
class="commentary-panel__handle-preview"
>
{{ leftHandlePreview }}
</div>
</Transition>
</div> </div>
<!-- Center controls --> <QBtn
<div class="commentary-panel__center-controls"> flat
<QBtn dense
flat round
dense icon="swap_horiz"
round class="commentary-panel__swap-btn"
icon="swap_horiz" @click="commentaryStore.swapCommentators"
class="commentary-panel__swap-btn" />
@click="commentaryStore.swapCommentators"
>
<QTooltip anchor="top middle" self="bottom middle">
{{ t('commentarySwap') }}
</QTooltip>
</QBtn>
<QBtn
flat
dense
round
icon="restart_alt"
class="commentary-panel__clear-btn"
:disable="!isAnythingFilled"
@click="clearAll"
>
<QTooltip anchor="top middle" self="bottom middle">
{{ t('commentaryClear') }}
</QTooltip>
</QBtn>
</div>
<!-- Commentator 2 -->
<div class="commentary-panel__commentator"> <div class="commentary-panel__commentator">
<QInput <QInput
v-model="commentaryStore.rightCommentator" v-model="commentaryStore.rightCommentator"
:label="t('commentaryCommentator2')" :label="t('commentaryCommentator2')"
@@ -150,23 +56,11 @@ const rightHandlePreview = computed(() =>
</QInput> </QInput>
<QInput <QInput
:model-value="commentaryStore.rightCommentatorTwitter" v-model="commentaryStore.rightCommentatorTwitter"
:label="t('commentaryTwitterText')" :label="t('commentaryTwitterText')"
:rules="twitterRules"
:maxlength="TWITTER_MAX_LENGTH"
dense dense
class="commentary-panel__field" class="commentary-panel__field"
@update:model-value="handleRightTwitterInput"
/> />
<Transition name="commentary-panel__preview">
<div
v-if="rightHandlePreview"
class="commentary-panel__handle-preview"
>
{{ rightHandlePreview }}
</div>
</Transition>
</div> </div>
</div> </div>
</div> </div>
@@ -195,35 +89,6 @@ const rightHandlePreview = computed(() =>
gap: 2px; gap: 2px;
} }
/* Center controls column */
.commentary-panel__center-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.commentary-panel__clear-btn {
color: rgba(255, 255, 255, 0.45);
transition: color 0.2s ease;
}
.commentary-panel__clear-btn:not(:disabled):hover {
color: rgba(255, 255, 255, 0.9);
}
/* Swap button */
.commentary-panel__swap-btn {
color: #fff;
opacity: 0.85;
}
.commentary-panel__swap-btn:hover {
opacity: 1;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.45);
}
/* Fields */
.commentary-panel__field :deep(.q-field__control) { .commentary-panel__field :deep(.q-field__control) {
min-height: 28px; min-height: 28px;
padding: 0; padding: 0;
@@ -247,27 +112,14 @@ const rightHandlePreview = computed(() =>
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
} }
/* Handle preview */ .commentary-panel__swap-btn {
.commentary-panel__handle-preview { color: #fff;
margin-top: 4px; opacity: 0.85;
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__preview-enter-active, .commentary-panel__swap-btn:hover {
.commentary-panel__preview-leave-active { opacity: 1;
transition: opacity 0.2s ease, transform 0.2s ease; text-shadow: 0 0 10px rgba(255, 255, 255, 0.45);
}
.commentary-panel__preview-enter-from,
.commentary-panel__preview-leave-to {
opacity: 0;
transform: translateY(-4px);
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@@ -275,9 +127,8 @@ const rightHandlePreview = computed(() =>
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.commentary-panel__center-controls { .commentary-panel__swap-btn {
justify-self: center; justify-self: center;
flex-direction: row;
} }
} }
</style> </style>
@@ -1,550 +0,0 @@
<script setup lang="ts">
import { computed, inject } from 'vue';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { usePlayerSide } from '../composables/usePlayerSide';
import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard';
// ---------------------------------------------------------------------------
// 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>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel class="scoreboard-preview__character-option">
{{ scope.opt.label }}
<span
v-if="scope.opt.dlc"
class="scoreboard-preview__dlc-badge"
>DLC</span>
</QItemLabel>
</QItemSection>
</QItem>
</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>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel class="scoreboard-preview__character-option">
{{ scope.opt.label }}
<span
v-if="scope.opt.dlc"
class="scoreboard-preview__dlc-badge"
>DLC</span>
</QItemLabel>
</QItemSection>
</QItem>
</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);
}
.scoreboard-preview__character-option {
display: flex;
align-items: center;
gap: 6px;
}
.scoreboard-preview__dlc-badge {
display: inline-flex;
align-items: center;
padding: 1px 5px;
border-radius: 3px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.05em;
line-height: 14px;
background: rgba(139, 92, 246, 0.2);
color: #a78bfa;
border: 1px solid rgba(139, 92, 246, 0.45);
flex-shrink: 0;
}
@media (max-width: 900px) {
.scoreboard-preview__image-wrap {
width: min(100%, 280px);
}
.scoreboard-preview__side-inner {
grid-template-columns: 1fr;
align-items: flex-start;
justify-items: flex-start;
}
.scoreboard-preview__side--right {
text-align: left;
}
.scoreboard-preview__side--right .scoreboard-preview__side-inner {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,191 +0,0 @@
<script setup lang="ts">
import { inject } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { t } from '../i18n';
const scoreboardStore = useScoreboardStore();
const { gameInput, fightingGameOptions, onGameFilter } = inject(CHARACTER_GAME_KEY)!;
const adjustLeftScore = (delta: number) => {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta);
};
const adjustRightScore = (delta: number) => {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta);
};
</script>
<template>
<div class="scoreboard-preview__center">
<QSelect
v-model="scoreboardStore.scoreboard.game"
v-model:input-value="gameInput"
:options="fightingGameOptions"
:label="t('scoreboardLabelGame')"
dense
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
class="scoreboard-preview__field scoreboard-preview__game-field"
@filter="onGameFilter"
>
<template #prepend>
<QIcon name="sports_esports" />
</template>
</QSelect>
<div class="scoreboard-preview__score-controls">
<div class="scoreboard-preview__score-side">
<QBtn
flat
dense
round
size="sm"
icon="add"
@click="adjustLeftScore(1)"
/>
<span class="scoreboard-preview__score-value">
{{ scoreboardStore.scoreboard.leftScore }}
</span>
<QBtn
flat
dense
round
size="sm"
icon="remove"
@click="adjustLeftScore(-1)"
/>
</div>
<span class="scoreboard-preview__dash">-</span>
<div class="scoreboard-preview__score-side">
<QBtn
flat
dense
round
size="sm"
icon="add"
@click="adjustRightScore(1)"
/>
<span class="scoreboard-preview__score-value">
{{ scoreboardStore.scoreboard.rightScore }}
</span>
<QBtn
flat
dense
round
size="sm"
icon="remove"
@click="adjustRightScore(-1)"
/>
</div>
</div>
<div class="scoreboard-preview__actions">
<QBtn
flat
dense
icon="swap_horiz"
class="scoreboard-preview__action-btn"
@click="scoreboardStore.swapPlayers"
/>
<QBtn
flat
dense
icon="restart_alt"
class="scoreboard-preview__action-btn"
@click="scoreboardStore.resetScores"
/>
</div>
</div>
</template>
<style scoped>
.scoreboard-preview__center {
display: flex;
flex-direction: column;
align-items: center;
align-self: stretch;
justify-content: flex-start;
padding-top: 2px;
gap: 10px;
}
.scoreboard-preview__game-field {
width: min(100%, 240px);
margin-bottom: 56px;
}
.scoreboard-preview__score-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 18px;
}
.scoreboard-preview__score-side {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.scoreboard-preview__score-value {
min-width: 64px;
text-align: center;
font-size: clamp(4rem, 7vw, 5.6rem);
font-weight: 800;
line-height: 1;
}
.scoreboard-preview__dash {
opacity: 0.7;
font-size: clamp(3rem, 5vw, 4rem);
font-weight: 700;
}
.scoreboard-preview__actions {
display: flex;
align-items: center;
gap: 10px;
}
.scoreboard-preview__action-btn {
color: #fff;
opacity: 0.85;
}
.scoreboard-preview__action-btn:hover {
opacity: 1;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.45);
}
/* Shared field styles (used by QSelect inside this panel) */
.scoreboard-preview__field {
margin: 0;
}
.scoreboard-preview__field :deep(.q-field__control) {
min-height: 28px;
padding: 0;
background: transparent !important;
border-radius: 0;
}
.scoreboard-preview__field :deep(.q-field__control:before),
.scoreboard-preview__field :deep(.q-field__control:after) {
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
}
.scoreboard-preview__field :deep(.q-field__native),
.scoreboard-preview__field :deep(.q-field__input),
.scoreboard-preview__field :deep(.q-field__label) {
color: rgba(255, 255, 255, 0.92);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,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,
};
}
+50 -133
View File
@@ -6,6 +6,7 @@ type Translations = {
menuDashboard: string; menuDashboard: string;
menuPlayers: string; menuPlayers: string;
menuGraphics: string; menuGraphics: string;
menuAssets: string;
menuSettings: string; menuSettings: string;
menuAbout: string; menuAbout: string;
settingsTitle: string; settingsTitle: string;
@@ -24,14 +25,6 @@ type Translations = {
settingsShortcutRightDecrementHint: string; settingsShortcutRightDecrementHint: string;
settingsShortcutReset: string; settingsShortcutReset: string;
settingsShortcutRecordingHint: string; settingsShortcutRecordingHint: string;
settingsShortcutConflictWarning: string;
settingsShortcutStartRecording: string;
settingsShortcutStopRecording: string;
settingsShortcutResetSingle: string;
settingsIntegrationsTitle: string;
settingsIntegrationsDescription: string;
settingsDisconnect: string;
settingsNotConnected: string;
languageEnglish: string; languageEnglish: string;
languageSpanish: string; languageSpanish: string;
scoreboardUnassigned: string; scoreboardUnassigned: string;
@@ -61,8 +54,6 @@ type Translations = {
aboutElectronNote: string; aboutElectronNote: string;
aboutUnknownReleaseError: string; aboutUnknownReleaseError: string;
aboutGitHubStatusError: string; aboutGitHubStatusError: string;
aboutChangelog: string;
aboutTechStackTitle: string;
graphicsTitle: string; graphicsTitle: string;
graphicsDescription: string; graphicsDescription: string;
graphicsNoConfigured: string; graphicsNoConfigured: string;
@@ -71,21 +62,14 @@ type Translations = {
graphicsScoreboard: string; graphicsScoreboard: string;
graphicsCommentary: string; graphicsCommentary: string;
graphicsSkinLabel: string; graphicsSkinLabel: string;
graphicsCopied: string;
graphicsOpenBrowser: string;
commentaryTitle: string; commentaryTitle: string;
commentaryCommentator1: string; commentaryCommentator1: string;
commentaryCommentator2: string; commentaryCommentator2: string;
commentaryTwitterText: string; commentaryTwitterText: string;
commentaryTwitterMaxLength: string;
commentaryTwitterInvalidChars: string;
commentarySwap: string;
commentaryClear: string;
bracketTitle: string; bracketTitle: string;
bracketStage: string; bracketStage: string;
bracketSide: string; bracketSide: string;
bracketCustomProgress: string; bracketCustomProgress: string;
bracketPreview: string;
playersLabelTeam: string; playersLabelTeam: string;
playersLabelCountry: string; playersLabelCountry: string;
playersLabelActions: string; playersLabelActions: string;
@@ -101,8 +85,6 @@ type Translations = {
playersSearchPlaceholder: string; playersSearchPlaceholder: string;
playersImport: string; playersImport: string;
playersExport: string; playersExport: string;
playersConnectInSettings: string;
playersConnectInSettingsSuffix: string;
}; };
const STORAGE_KEY = 'scoreko-dev.language'; const STORAGE_KEY = 'scoreko-dev.language';
@@ -112,44 +94,31 @@ const messages: Record<Locale, Translations> = {
menuDashboard: 'Dashboard', menuDashboard: 'Dashboard',
menuPlayers: 'Players', menuPlayers: 'Players',
menuGraphics: 'Graphics', menuGraphics: 'Graphics',
menuAssets: 'Game Assets',
menuSettings: 'Settings', menuSettings: 'Settings',
menuAbout: 'About', menuAbout: 'About',
// ── Settings ────────────────────────────────────────────────────────────
settingsTitle: 'Settings', settingsTitle: 'Settings',
settingsDescription: 'Dashboard and bundle settings.', settingsDescription: 'Dashboard and bundle configuration.',
settingsLanguageLabel: 'Language', settingsLanguageLabel: 'Language',
settingsLanguageHint: 'Choose the dashboard language.', settingsLanguageHint: 'Choose the dashboard language.',
settingsShortcutTitle: 'Keyboard shortcuts', settingsShortcutTitle: 'Keyboard shortcuts',
settingsShortcutDescription: 'Configure keyboard shortcuts to update each sides score.', settingsShortcutDescription: 'Configure quick keys to update the score for each side.',
settingsShortcutLeftIncrementLabel: 'P1 score +1', settingsShortcutLeftIncrementLabel: 'P1 score +1',
settingsShortcutLeftIncrementHint: 'Increases the left players score by one.', settingsShortcutLeftIncrementHint: 'Increases left player score by one.',
settingsShortcutLeftDecrementLabel: 'P1 score -1', settingsShortcutLeftDecrementLabel: 'P1 score -1',
settingsShortcutLeftDecrementHint: 'Decreases the left players score by one.', settingsShortcutLeftDecrementHint: 'Decreases left player score by one.',
settingsShortcutRightIncrementLabel: 'P2 score +1', settingsShortcutRightIncrementLabel: 'P2 score +1',
settingsShortcutRightIncrementHint: 'Increases the right players score by one.', settingsShortcutRightIncrementHint: 'Increases right player score by one.',
settingsShortcutRightDecrementLabel: 'P2 score -1', settingsShortcutRightDecrementLabel: 'P2 score -1',
settingsShortcutRightDecrementHint: 'Decreases the right players score by one.', settingsShortcutRightDecrementHint: 'Decreases right player score by one.',
settingsShortcutReset: 'Reset shortcuts', settingsShortcutReset: 'Reset shortcuts',
settingsShortcutRecordingHint: 'Press the desired shortcut now (for example: Alt+1).', settingsShortcutRecordingHint: 'Press the desired shortcut now (example: Alt+1).',
settingsShortcutConflictWarning: 'This shortcut is already assigned to another action.',
settingsShortcutStartRecording: 'Start recording shortcut',
settingsShortcutStopRecording: 'Stop recording shortcut',
settingsShortcutResetSingle: 'Reset this shortcut',
settingsIntegrationsTitle: 'Integrations',
settingsIntegrationsDescription: 'Connect your tournament platform accounts to import players directly from brackets.',
settingsDisconnect: 'Disconnect',
settingsNotConnected: 'Not connected',
// ── Language ─────────────────────────────────────────────────────────────
languageEnglish: 'English', languageEnglish: 'English',
languageSpanish: 'Spanish', languageSpanish: 'Spanish',
// ── Scoreboard ───────────────────────────────────────────────────────────
scoreboardUnassigned: '(Unassigned)', scoreboardUnassigned: '(Unassigned)',
scoreboardLeft: 'Left', scoreboardLeft: 'Left',
scoreboardRight: 'Right', scoreboardRight: 'Right',
scoreboardPreview: 'Preview', scoreboardPreview: 'preview',
scoreboardLeftImage: 'Left image', scoreboardLeftImage: 'Left image',
scoreboardRightImage: 'Right image', scoreboardRightImage: 'Right image',
scoreboardLabelCharacter: 'Character', scoreboardLabelCharacter: 'Character',
@@ -157,13 +126,11 @@ const messages: Record<Locale, Translations> = {
scoreboardLabelTeam: 'Team', scoreboardLabelTeam: 'Team',
scoreboardLabelCountry: 'Country', scoreboardLabelCountry: 'Country',
scoreboardLabelGame: 'Game', scoreboardLabelGame: 'Game',
// ── About ────────────────────────────────────────────────────────────────
aboutTitle: 'About', aboutTitle: 'About',
aboutVersion: 'Version', aboutVersion: 'Version',
aboutDescription: 'Dashboard for producing fighting game overlays with NodeCG, Vue, and Quasar.', aboutDescription: 'Dashboard for producing fighting game overlays using NodeCG, Vue, and Quasar.',
aboutFrameworkNodeCG: 'NodeCG framework', aboutFrameworkNodeCG: 'Framework NodeCG',
aboutCollaboratorsTitle: 'Contributors and acknowledgments', aboutCollaboratorsTitle: 'Collaborators and acknowledgments',
aboutUpdateSystemTitle: 'Update system (GitHub Releases)', aboutUpdateSystemTitle: 'Update system (GitHub Releases)',
aboutUpdateSystemDescription: 'This check fetches the latest release from the repository and compares it with the current version.', aboutUpdateSystemDescription: 'This check fetches the latest release from the repository and compares it with the current version.',
aboutCheckUpdates: 'Check for updates', aboutCheckUpdates: 'Check for updates',
@@ -172,49 +139,32 @@ const messages: Record<Locale, Translations> = {
aboutUpdateAvailable: 'A newer version is available.', aboutUpdateAvailable: 'A newer version is available.',
aboutUpToDate: 'Your version is up to date with the latest release.', aboutUpToDate: 'Your version is up to date with the latest release.',
aboutViewRelease: 'View release', aboutViewRelease: 'View release',
aboutElectronNote: 'Note for Electron: this panel only implements detection and notification. For real automatic desktop updates, you need to integrate autoUpdater into Electrons main process and publish signed artifacts per platform.', aboutElectronNote: 'Note for Electron: this panel only implements detection and notification. For real automatic desktop updates, you need to integrate autoUpdater into Electron\'s main process and publish signed artifacts per platform.',
aboutUnknownReleaseError: 'Unknown error while checking releases.', aboutUnknownReleaseError: 'Unknown error while checking releases.',
aboutGitHubStatusError: 'GitHub responded with status', aboutGitHubStatusError: 'GitHub responded with status',
aboutChangelog: 'Changelog',
aboutTechStackTitle: 'Tech stack',
// ── Graphics ─────────────────────────────────────────────────────────────
graphicsTitle: 'Graphics', graphicsTitle: 'Graphics',
graphicsDescription: 'Controls and status for bundle graphics.', graphicsDescription: 'Bundle graphics controls and status.',
graphicsNoConfigured: 'There are no graphics configured in this bundle.', graphicsNoConfigured: 'There are no graphics configured in this bundle.',
graphicsCopyUrl: 'Copy URL', graphicsCopyUrl: 'Copy URL',
graphicsDragObs: 'Drag into OBS', graphicsDragObs: 'Drag into OBS',
graphicsScoreboard: 'Scoreboard', graphicsScoreboard: 'Scoreboard',
graphicsCommentary: 'Commentators', graphicsCommentary: 'Commentary',
graphicsSkinLabel: 'Theme', graphicsSkinLabel: 'Skin',
graphicsCopied: 'URL copied to clipboard', commentaryTitle: 'Commentary',
graphicsOpenBrowser: 'Open in browser',
// ── Commentary ───────────────────────────────────────────────────────────
commentaryTitle: 'Commentators',
commentaryCommentator1: 'Commentator #1', commentaryCommentator1: 'Commentator #1',
commentaryCommentator2: 'Commentator #2', commentaryCommentator2: 'Commentator #2',
commentaryTwitterText: 'Twitter / Text', commentaryTwitterText: '@Twitter / Text',
commentaryTwitterMaxLength: 'Twitter character limit exceeded',
commentaryTwitterInvalidChars: 'Invalid characters in Twitter text',
commentarySwap: 'Swap commentators',
commentaryClear: 'Clear commentators',
// ── Bracket ──────────────────────────────────────────────────────────────
bracketTitle: 'Bracket', bracketTitle: 'Bracket',
bracketStage: 'Stage', bracketStage: 'Stage',
bracketSide: 'Bracket side', bracketSide: 'Bracket side',
bracketCustomProgress: 'Custom progress', bracketCustomProgress: 'Custom progress',
bracketPreview: 'Preview',
// ── Players ──────────────────────────────────────────────────────────────
playersLabelTeam: 'Team', playersLabelTeam: 'Team',
playersLabelCountry: 'Country', playersLabelCountry: 'Country',
playersLabelActions: 'Actions', playersLabelActions: 'Actions',
playersStartggHelp: 'Connect via OAuth (recommended) or paste your personal token to load tournaments you created or manage.', playersStartggHelp: 'Connect via OAuth (recommended) or paste your personal token to load tournaments you created or administrate. If you see "Client authentication failed", verify your config uses the Client ID/Secret from a start.gg OAuth App.',
playersConnectStartgg: 'Connect with start.gg', playersConnectStartgg: 'Connect with start.gg',
playersConnected: 'Connected', playersConnected: 'Connected',
playersUsePersonalApi: 'Use personal token', playersUsePersonalApi: 'Use personal API',
playersTournament: 'Tournament', playersTournament: 'Tournament',
playersImportPlayers: 'Import players', playersImportPlayers: 'Import players',
playersChallongeHelp: 'Connect with OAuth or paste your personal token to load your Challonge tournaments and import participants.', playersChallongeHelp: 'Connect with OAuth or paste your personal token to load your Challonge tournaments and import participants.',
@@ -223,48 +173,32 @@ const messages: Record<Locale, Translations> = {
playersSearchPlaceholder: 'Search...', playersSearchPlaceholder: 'Search...',
playersImport: 'Import', playersImport: 'Import',
playersExport: 'Export', playersExport: 'Export',
playersConnectInSettings: 'Connect your account in',
playersConnectInSettingsSuffix: 'to import players from tournaments.',
}, },
es: { es: {
menuDashboard: 'Panel', menuDashboard: 'Panel',
menuPlayers: 'Jugadores', menuPlayers: 'Jugadores',
menuGraphics: 'Gráficos', menuGraphics: 'Gráficos',
menuAssets: 'Assets de juego',
menuSettings: 'Configuración', menuSettings: 'Configuración',
menuAbout: 'Acerca de', menuAbout: 'Acerca de',
// ── Settings ────────────────────────────────────────────────────────────
settingsTitle: 'Configuración', settingsTitle: 'Configuración',
settingsDescription: 'Configuración del panel y del bundle.', settingsDescription: 'Configuración del dashboard y del bundle.',
settingsLanguageLabel: 'Idioma', settingsLanguageLabel: 'Idioma',
settingsLanguageHint: 'Selecciona el idioma del dashboard.', settingsLanguageHint: 'Selecciona el idioma del dashboard.',
settingsShortcutTitle: 'Atajos de teclado', settingsShortcutTitle: 'Atajos de teclado',
settingsShortcutDescription: 'Configura atajos para actualizar el marcador de cada lado.', settingsShortcutDescription: 'Configura teclas rápidas para actualizar el score de cada lado.',
settingsShortcutLeftIncrementLabel: 'Marcador P1 +1', settingsShortcutLeftIncrementLabel: 'Score P1 +1',
settingsShortcutLeftIncrementHint: 'Incrementa en uno el marcador del jugador izquierdo.', settingsShortcutLeftIncrementHint: 'Incrementa en uno el score del jugador izquierdo.',
settingsShortcutLeftDecrementLabel: 'Marcador P1 -1', settingsShortcutLeftDecrementLabel: 'Score P1 -1',
settingsShortcutLeftDecrementHint: 'Reduce en uno el marcador del jugador izquierdo.', settingsShortcutLeftDecrementHint: 'Reduce en uno el score del jugador izquierdo.',
settingsShortcutRightIncrementLabel: 'Marcador P2 +1', settingsShortcutRightIncrementLabel: 'Score P2 +1',
settingsShortcutRightIncrementHint: 'Incrementa en uno el marcador del jugador derecho.', settingsShortcutRightIncrementHint: 'Incrementa en uno el score del jugador derecho.',
settingsShortcutRightDecrementLabel: 'Marcador P2 -1', settingsShortcutRightDecrementLabel: 'Score P2 -1',
settingsShortcutRightDecrementHint: 'Reduce en uno el marcador del jugador derecho.', settingsShortcutRightDecrementHint: 'Reduce en uno el score del jugador derecho.',
settingsShortcutReset: 'Restablecer atajos', settingsShortcutReset: 'Restablecer atajos',
settingsShortcutRecordingHint: 'Pulsa ahora el atajo deseado (ejemplo: Alt+1).', settingsShortcutRecordingHint: 'Pulsa ahora el atajo deseado (ejemplo: Alt+1).',
settingsShortcutConflictWarning: 'Este atajo ya está asignado a otra acción.',
settingsShortcutStartRecording: 'Iniciar grabación de atajo',
settingsShortcutStopRecording: 'Detener grabación de atajo',
settingsShortcutResetSingle: 'Restablecer este atajo',
settingsIntegrationsTitle: 'Integraciones',
settingsIntegrationsDescription: 'Conecta tus cuentas de plataformas de torneos para importar jugadores directamente desde los brackets.',
settingsDisconnect: 'Desconectar',
settingsNotConnected: 'No conectado',
// ── Language ─────────────────────────────────────────────────────────────
languageEnglish: 'Inglés', languageEnglish: 'Inglés',
languageSpanish: 'Español', languageSpanish: 'Castellano',
// ── Scoreboard ───────────────────────────────────────────────────────────
scoreboardUnassigned: '(Sin asignar)', scoreboardUnassigned: '(Sin asignar)',
scoreboardLeft: 'Izquierda', scoreboardLeft: 'Izquierda',
scoreboardRight: 'Derecha', scoreboardRight: 'Derecha',
@@ -276,61 +210,42 @@ const messages: Record<Locale, Translations> = {
scoreboardLabelTeam: 'Equipo', scoreboardLabelTeam: 'Equipo',
scoreboardLabelCountry: 'País', scoreboardLabelCountry: 'País',
scoreboardLabelGame: 'Juego', scoreboardLabelGame: 'Juego',
// ── About ────────────────────────────────────────────────────────────────
aboutTitle: 'Acerca de', aboutTitle: 'Acerca de',
aboutVersion: 'Versión', aboutVersion: 'Versión',
aboutDescription: 'Panel para producir overlays de juegos de lucha usando NodeCG, Vue y Quasar.', aboutDescription: 'Dashboard para producir overlays de juegos de lucha usando NodeCG, Vue y Quasar.',
aboutFrameworkNodeCG: 'Framework NodeCG', aboutFrameworkNodeCG: 'Framework NodeCG',
aboutCollaboratorsTitle: 'Colaboradores y agradecimientos', aboutCollaboratorsTitle: 'Colaboradores y agradecimientos',
aboutUpdateSystemTitle: 'Sistema de actualizaciones (GitHub Releases)', aboutUpdateSystemTitle: 'Sistema de actualizaciones (GitHub Releases)',
aboutUpdateSystemDescription: 'Esta comprobación obtiene la última versión publicada del repositorio y la compara con la versión actual.', aboutUpdateSystemDescription: 'Esta comprobación obtiene la última release del repositorio y la compara con la versión actual.',
aboutCheckUpdates: 'Buscar actualizaciones', aboutCheckUpdates: 'Buscar actualizaciones',
aboutLatestRelease: 'Última versión', aboutLatestRelease: 'Última release',
aboutPublished: 'Publicado', aboutPublished: 'Publicado',
aboutUpdateAvailable: 'Hay una versión más nueva disponible.', aboutUpdateAvailable: 'Hay una versión más nueva disponible.',
aboutUpToDate: 'Tu versión está actualizada con la última versión.', aboutUpToDate: 'Tu versión está actualizada con la última release.',
aboutViewRelease: 'Ver versión', aboutViewRelease: 'Ver release',
aboutElectronNote: 'Nota para Electron: este panel solo implementa detección y notificación. Para actualizaciones automáticas reales de escritorio, debes integrar autoUpdater en el proceso principal de Electron y publicar artefactos firmados por plataforma.', aboutElectronNote: 'Nota para Electron: este panel solo implementa detección y notificación. Para actualizaciones automáticas reales de escritorio, debes integrar autoUpdater en el proceso principal de Electron y publicar artefactos firmados por plataforma.',
aboutUnknownReleaseError: 'Error desconocido al consultar releases.', aboutUnknownReleaseError: 'Error desconocido al consultar releases.',
aboutGitHubStatusError: 'GitHub respondió con estado', aboutGitHubStatusError: 'GitHub respondió con estado',
aboutChangelog: 'Registro de cambios',
aboutTechStackTitle: 'Stack tecnológico',
// ── Graphics ─────────────────────────────────────────────────────────────
graphicsTitle: 'Gráficos', graphicsTitle: 'Gráficos',
graphicsDescription: 'Controles y estado de los gráficos del bundle.', graphicsDescription: 'Controles y estado de los gráficos del bundle.',
graphicsNoConfigured: 'No hay gráficos configurados en este bundle.', graphicsNoConfigured: 'No hay gráficos configurados en este bundle.',
graphicsCopyUrl: 'Copiar URL', graphicsCopyUrl: 'Copiar URL',
graphicsDragObs: 'Arrastrar a OBS', graphicsDragObs: 'Arrastrar a OBS',
graphicsScoreboard: 'Marcador', graphicsScoreboard: 'Scoreboard',
graphicsCommentary: 'Comentaristas', graphicsCommentary: 'Comentario',
graphicsSkinLabel: 'Tema', graphicsSkinLabel: 'Skin',
graphicsCopied: 'URL copiada al portapapeles', commentaryTitle: 'Comentario',
graphicsOpenBrowser: 'Abrir en el navegador',
// ── Commentary ───────────────────────────────────────────────────────────
commentaryTitle: 'Comentaristas',
commentaryCommentator1: 'Comentarista #1', commentaryCommentator1: 'Comentarista #1',
commentaryCommentator2: 'Comentarista #2', commentaryCommentator2: 'Comentarista #2',
commentaryTwitterText: '@Twitter / Texto', commentaryTwitterText: '@Twitter / Texto',
commentaryTwitterMaxLength: 'Se excedió el límite de caracteres de Twitter', bracketTitle: 'Bracket',
commentaryTwitterInvalidChars: 'Caracteres inválidos en el texto de Twitter',
commentarySwap: 'Intercambiar comentaristas',
commentaryClear: 'Limpiar comentaristas',
// ── Bracket ──────────────────────────────────────────────────────────────
bracketTitle: 'Llave',
bracketStage: 'Etapa', bracketStage: 'Etapa',
bracketSide: 'Lado de la llave', bracketSide: 'Lado del bracket',
bracketCustomProgress: 'Progreso personalizado', bracketCustomProgress: 'Progreso personalizado',
bracketPreview: 'Vista previa',
// ── Players ──────────────────────────────────────────────────────────────
playersLabelTeam: 'Equipo', playersLabelTeam: 'Equipo',
playersLabelCountry: 'País', playersLabelCountry: 'País',
playersLabelActions: 'Acciones', playersLabelActions: 'Acciones',
playersStartggHelp: 'Conéctate por OAuth (recomendado) o pega tu token personal para cargar torneos que creaste o administras.', playersStartggHelp: 'Conéctate por OAuth (recomendado) o pega tu token personal para cargar torneos que creaste o administras. Si ves "Client authentication failed", revisa que tu configuración use el Client ID/Secret de una app OAuth de start.gg.',
playersConnectStartgg: 'Conectar con start.gg', playersConnectStartgg: 'Conectar con start.gg',
playersConnected: 'Conectado', playersConnected: 'Conectado',
playersUsePersonalApi: 'Usar API personal', playersUsePersonalApi: 'Usar API personal',
@@ -342,15 +257,16 @@ const messages: Record<Locale, Translations> = {
playersSearchPlaceholder: 'Buscar...', playersSearchPlaceholder: 'Buscar...',
playersImport: 'Importar', playersImport: 'Importar',
playersExport: 'Exportar', playersExport: 'Exportar',
playersConnectInSettings: 'Conecta tu cuenta en',
playersConnectInSettingsSuffix: 'para importar jugadores desde torneos.',
}, },
}; };
const normalizeLocale = (value: unknown): Locale => (value === 'es' ? 'es' : 'en'); const normalizeLocale = (value: unknown): Locale => (value === 'es' ? 'es' : 'en');
const getStoredLocale = (): Locale => { const getStoredLocale = (): Locale => {
if (typeof window === 'undefined') return 'en'; if (typeof window === 'undefined') {
return 'en';
}
return normalizeLocale(localStorage.getItem(STORAGE_KEY)); return normalizeLocale(localStorage.getItem(STORAGE_KEY));
}; };
@@ -358,6 +274,7 @@ export const locale = ref<Locale>(getStoredLocale());
export const setLocale = (value: Locale) => { export const setLocale = (value: Locale) => {
locale.value = normalizeLocale(value); locale.value = normalizeLocale(value);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, locale.value); localStorage.setItem(STORAGE_KEY, locale.value);
} }
+91 -326
View File
@@ -1,87 +1,68 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, onMounted, onUnmounted } from 'vue';
import { t } from './i18n'; import { t } from './i18n';
import { useScoreboardStore } from './stores/scoreboard'; import { useScoreboardStore } from './stores/scoreboard';
import { isShortcutMatch, useShortcutSettingsStore } from './stores/shortcut-settings'; import { isShortcutMatch, useShortcutSettingsStore } from './stores/shortcut-settings';
// ── Sidebar collapse ────────────────────────────────────────────────────────── const menuItems = computed(() => [
const LS_KEY = 'sidebar_collapsed'; { label: t('menuDashboard'), to: '/', icon: 'dashboard' },
const isCollapsed = ref(localStorage.getItem(LS_KEY) === 'true'); { label: t('menuPlayers'), to: '/players', icon: 'groups' },
const drawerWidth = computed(() => (isCollapsed.value ? 60 : 220)); { label: t('menuGraphics'), to: '/graphics', icon: 'collections' },
watch(isCollapsed, (val) => localStorage.setItem(LS_KEY, String(val))); { label: t('menuAssets'), to: '/game-assets', icon: 'sports_esports' },
const toggleCollapse = () => { isCollapsed.value = !isCollapsed.value; };
// ── Version ───────────────────────────────────────────────────────────────────
const appVersion = import.meta.env.PACKAGE_VERSION as string | undefined;
// ── Logo ──────────────────────────────────────────────────────────────────────
const logoUrl = new URL('./image.png', import.meta.url).href;
// ── Menu groups ───────────────────────────────────────────────────────────────
const mainItems = computed(() => [
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' },
{ label: t('menuPlayers'), to: '/players', icon: 'groups' },
{ label: t('menuGraphics'), to: '/graphics', icon: 'collections' },
]);
const configItems = computed(() => [
{ label: t('menuSettings'), to: '/settings', icon: 'settings' }, { label: t('menuSettings'), to: '/settings', icon: 'settings' },
{ label: t('menuAbout'), to: '/about', icon: 'info' }, { label: t('menuAbout'), to: '/about', icon: 'info' },
]); ]);
// ── Online / Offline ────────────────────────────────────────────────────────── const logoUrl = new URL('./image.png', import.meta.url).href;
const isOnline = ref(navigator.onLine); const scoreboardStore = useScoreboardStore();
const checkOnline = async () => {
try {
await fetch('https://www.google.com/favicon.ico', {
method: 'HEAD',
mode: 'no-cors',
cache: 'no-store',
});
isOnline.value = true;
} catch {
isOnline.value = false;
}
};
const onNetworkOnline = () => { isOnline.value = true; };
const onNetworkOffline = () => { isOnline.value = false; };
let pingInterval: ReturnType<typeof setInterval> | null = null;
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
const scoreboardStore = useScoreboardStore();
const shortcutSettingsStore = useShortcutSettingsStore(); const shortcutSettingsStore = useShortcutSettingsStore();
const isEditableTarget = (target: EventTarget | null): boolean => { const isEditableTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) return false; if (!(target instanceof HTMLElement)) {
return ( return false;
target.isContentEditable || }
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) ||
Boolean(target.closest('[contenteditable="true"]')) return target.isContentEditable
); || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
|| Boolean(target.closest('[contenteditable="true"]'));
}; };
const onShortcutPress = (event: KeyboardEvent) => { const onShortcutPress = (event: KeyboardEvent) => {
if (isEditableTarget(event.target) || document.body.dataset.shortcutRecording === 'true') return; if (isEditableTarget(event.target) || document.body.dataset.shortcutRecording === 'true') {
return;
}
const { shortcuts } = shortcutSettingsStore; const { shortcuts } = shortcutSettingsStore;
if (isShortcutMatch(event, shortcuts.leftIncrement)) { scoreboardStore.leftScore += 1; event.preventDefault(); return; } if (isShortcutMatch(event, shortcuts.leftIncrement)) {
if (isShortcutMatch(event, shortcuts.leftDecrement)) { scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore - 1); event.preventDefault(); return; } scoreboardStore.leftScore += 1;
if (isShortcutMatch(event, shortcuts.rightIncrement)) { scoreboardStore.rightScore += 1; event.preventDefault(); return; } event.preventDefault();
if (isShortcutMatch(event, shortcuts.rightDecrement)) { scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore - 1); event.preventDefault(); } return;
}
if (isShortcutMatch(event, shortcuts.leftDecrement)) {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore - 1);
event.preventDefault();
return;
}
if (isShortcutMatch(event, shortcuts.rightIncrement)) {
scoreboardStore.rightScore += 1;
event.preventDefault();
return;
}
if (isShortcutMatch(event, shortcuts.rightDecrement)) {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore - 1);
event.preventDefault();
}
}; };
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', onShortcutPress); window.addEventListener('keydown', onShortcutPress);
window.addEventListener('online', onNetworkOnline);
window.addEventListener('offline', onNetworkOffline);
pingInterval = setInterval(checkOnline, 15_000);
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', onShortcutPress); window.removeEventListener('keydown', onShortcutPress);
window.removeEventListener('online', onNetworkOnline);
window.removeEventListener('offline', onNetworkOffline);
if (pingInterval) clearInterval(pingInterval);
}); });
</script> </script>
@@ -91,117 +72,49 @@ onUnmounted(() => {
show-if-above show-if-above
side="left" side="left"
bordered bordered
:width="drawerWidth" :width="220"
class="sidebar-drawer" class="sidebar-drawer"
> >
<!-- Header --> <div class="sidebar-header q-pa-md">
<div class="sidebar-header" :class="{ 'is-collapsed': isCollapsed }"> <div class="row items-center no-wrap">
<img :src="logoUrl" alt="Logo" class="sidebar-logo"> <img
:src="logoUrl"
<Transition name="slide-fade"> alt="Logo"
<div v-if="!isCollapsed" class="sidebar-title"> class="sidebar-logo"
<span class="title-text">Scoreko-dev</span> >
<span v-if="appVersion" class="title-version">v{{ appVersion }}</span> <div class="q-ml-sm">
<div class="text-subtitle1 text-weight-bold">
Scoreko-dev
</div>
<div class="text-caption">
<span class="by-label">by</span> <a
class="by-link"
href="https://github.com/Pandipipas"
target="_blank"
rel="noopener"
>Pandipipas</a>
</div>
</div> </div>
</Transition>
<!-- Chevron siempre visible, arriba a la derecha -->
<QBtn
flat
round
dense
size="sm"
:icon="isCollapsed ? 'chevron_right' : 'chevron_left'"
class="collapse-btn"
@click="toggleCollapse"
/>
</div>
<QSeparator />
<!-- Sección MAIN -->
<div class="section-sep" :class="{ 'is-collapsed': isCollapsed }">
<span v-if="!isCollapsed" class="section-label">MAIN</span>
</div>
<QList padding>
<QItem
v-for="item in mainItems"
:key="item.to"
clickable
:to="item.to"
exact
active-class="sidebar-item-active"
:class="{ 'nav-item-collapsed': isCollapsed }"
>
<QItemSection avatar>
<QIcon :name="item.icon" size="sm" />
</QItemSection>
<QItemSection v-if="!isCollapsed">
<QItemLabel>{{ item.label }}</QItemLabel>
</QItemSection>
<QTooltip
v-if="isCollapsed"
anchor="center right"
self="center left"
:offset="[10, 0]"
>
{{ item.label }}
</QTooltip>
</QItem>
</QList>
<!-- Sección CONFIG -->
<div class="section-sep" :class="{ 'is-collapsed': isCollapsed }">
<span v-if="!isCollapsed" class="section-label">CONFIG</span>
</div>
<QList padding>
<QItem
v-for="item in configItems"
:key="item.to"
clickable
:to="item.to"
exact
active-class="sidebar-item-active"
:class="{ 'nav-item-collapsed': isCollapsed }"
>
<QItemSection avatar>
<QIcon :name="item.icon" size="sm" />
</QItemSection>
<QItemSection v-if="!isCollapsed">
<QItemLabel>{{ item.label }}</QItemLabel>
</QItemSection>
<QTooltip
v-if="isCollapsed"
anchor="center right"
self="center left"
:offset="[10, 0]"
>
{{ item.label }}
</QTooltip>
</QItem>
</QList>
<!-- Footer: Online / Offline -->
<div class="sidebar-footer" :class="{ 'is-collapsed': isCollapsed }">
<div class="online-row">
<span class="online-dot" :class="isOnline ? 'dot-online' : 'dot-offline'" />
<Transition name="slide-fade">
<span v-if="!isCollapsed" class="online-label">
{{ isOnline ? 'Online' : 'Offline' }}
</span>
</Transition>
<QTooltip
v-if="isCollapsed"
anchor="center right"
self="center left"
:offset="[10, 0]"
>
{{ isOnline ? 'Online' : 'Offline' }}
</QTooltip>
</div> </div>
</div> </div>
<QSeparator class="q-mb-sm" />
<QList>
<QItem
v-for="item in menuItems"
:key="item.to"
clickable
:to="item.to"
exact
active-class="sidebar-item-active"
>
<QItemSection avatar>
<QIcon :name="item.icon" />
</QItemSection>
<QItemSection>
<QItemLabel>{{ item.label }}</QItemLabel>
</QItemSection>
</QItem>
</QList>
</QDrawer> </QDrawer>
<QPageContainer> <QPageContainer>
@@ -211,176 +124,28 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
/* ── Drawer shell ─────────────────────────────────────────────────────────── */
.sidebar-drawer :deep(.q-drawer__content) {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* ── Header ───────────────────────────────────────────────────────────────── */
.sidebar-header { .sidebar-header {
display: flex; min-height: 72px;
align-items: center;
gap: 10px;
padding: 14px 12px 14px 14px;
min-height: 64px;
position: relative;
flex-shrink: 0;
transition: padding 0.25s ease;
}
.sidebar-header.is-collapsed {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 4px;
} }
.sidebar-logo { .sidebar-logo {
width: 36px; width: 40px;
height: 36px; height: 40px;
object-fit: contain; object-fit: contain;
flex-shrink: 0;
} }
.sidebar-title { .by-label {
display: flex; font-size: 0.75rem;
flex-direction: column;
overflow: hidden;
flex: 1;
}
.title-text {
font-size: 0.875rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title-version {
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 0.65rem;
letter-spacing: 0.05em;
opacity: 0.45;
margin-top: 1px;
} }
/* ── Collapse button ──────────────────────────────────────────────────────── */ .by-link {
.collapse-btn { font-size: 0.75rem;
flex-shrink: 0; color: #f50a64;
opacity: 0.4; text-decoration: none;
transition: opacity 0.2s ease, transform 0.25s ease;
}
.collapse-btn:hover {
opacity: 1;
}
/* en modo expandido queda al extremo derecho */
.sidebar-header:not(.is-collapsed) .collapse-btn {
margin-left: auto;
} }
/* ── Section separators ───────────────────────────────────────────────────── */ .by-link:hover {
.section-sep { text-decoration: underline;
display: flex;
align-items: center;
padding: 10px 14px 2px;
min-height: 28px;
transition: padding 0.2s ease, min-height 0.2s ease;
}
.section-sep.is-collapsed {
padding: 6px 12px 2px;
min-height: 0;
}
.section-sep.is-collapsed::after {
content: '';
display: block;
width: 100%;
height: 1px;
background: currentColor;
opacity: 0.12;
} }
.section-label {
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
opacity: 0.38;
}
/* ── Nav items (collapsed centrado) ──────────────────────────────────────── */
.nav-item-collapsed {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.nav-item-collapsed :deep(.q-item__section--avatar) {
min-width: unset;
padding-right: 0;
}
/* ── Footer ───────────────────────────────────────────────────────────────── */
.sidebar-footer {
margin-top: auto;
padding: 10px 14px;
border-top: 1px solid rgba(128, 128, 128, 0.15);
flex-shrink: 0;
transition: padding 0.25s ease;
}
.sidebar-footer.is-collapsed {
padding: 10px 0;
display: flex;
justify-content: center;
}
.online-row {
display: flex;
align-items: center;
gap: 8px;
position: relative;
}
/* ── Online dot ───────────────────────────────────────────────────────────── */
.online-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
display: inline-block;
}
.dot-online {
background: #22c55e;
animation: pulse-green 2s ease-in-out infinite;
}
.dot-offline {
background: #ef4444;
}
.online-label {
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 0.7rem;
letter-spacing: 0.04em;
opacity: 0.6;
white-space: nowrap;
}
/* ── Transitions ──────────────────────────────────────────────────────────── */
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
opacity: 0;
transform: translateX(-6px);
}
/* ── Pulse animation ──────────────────────────────────────────────────────── */
@keyframes pulse-green {
0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.55); }
70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
}
</style> </style>
+2
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
import AboutView from './views/About.vue'; import AboutView from './views/About.vue';
import DashboardView from './views/Dashboard.vue'; import DashboardView from './views/Dashboard.vue';
import GraphicsView from './views/Graphics.vue'; import GraphicsView from './views/Graphics.vue';
import GameAssetsView from './views/GameAssets.vue';
import PlayersView from './views/Players.vue'; import PlayersView from './views/Players.vue';
import SettingsView from './views/Settings.vue'; import SettingsView from './views/Settings.vue';
@@ -11,6 +12,7 @@ const router = createRouter({
{ path: '/', name: 'dashboard', component: DashboardView }, { path: '/', name: 'dashboard', component: DashboardView },
{ path: '/players', name: 'players', component: PlayersView }, { path: '/players', name: 'players', component: PlayersView },
{ path: '/graphics', name: 'graphics', component: GraphicsView }, { path: '/graphics', name: 'graphics', component: GraphicsView },
{ path: '/game-assets', name: 'game-assets', component: GameAssetsView },
{ path: '/settings', name: 'settings', component: SettingsView }, { path: '/settings', name: 'settings', component: SettingsView },
{ path: '/about', name: 'about', component: AboutView }, { 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); persistSettings(shortcuts);
}; };
const resetShortcut = (action: ShortcutAction) => {
shortcuts[action] = defaultShortcuts[action];
persistSettings(shortcuts);
};
return { return {
shortcuts, shortcuts,
setShortcut, setShortcut,
resetShortcuts, resetShortcuts,
resetShortcut,
}; };
}); });
+233 -205
View File
@@ -1,252 +1,280 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, onMounted, ref } from 'vue';
import { t } from '../i18n'; import { t } from '../i18n';
defineOptions({ name: 'AboutView' }); defineOptions({ name: 'AboutView' });
useHead(() => ({ title: t('aboutTitle') })); useHead(() => ({ title: t('aboutTitle') }));
type ReleaseResponse = {
html_url: string;
name: string | null;
tag_name: string;
published_at: string;
};
const appName = 'Scoreko-dev'; const appName = 'Scoreko-dev';
const currentVersion = import.meta.env.PACKAGE_VERSION; const currentVersion = import.meta.env.PACKAGE_VERSION;
const repoUrl = 'https://github.com/Pandipipas/scoreko-dev'; const updateRepoOwner = 'Pandipipas';
const authorUrl = 'https://github.com/Pandipipas'; const updateRepoName = 'scoreko';
const checkingUpdates = ref(false);
const updateError = ref('');
const latestRelease = ref<ReleaseResponse | null>(null);
const collaborators = [ const collaborators = [
{ {
name: 'Pandipipas', name: 'Pandipipas',
role: 'Development and maintenance of Scoreko-dev', role: 'Development and maintenance of Scoreko-dev',
url: authorUrl, url: 'https://github.com/Pandipipas/scoreko-dev'
icon: 'code',
}, },
{ {
name: 'Dan Shields', name: 'Dan Shields',
role: 'nodecg-vue-composable helper', role: 'nodecg-vue-composable helper',
url: 'https://github.com/Dan-Shields/nodecg-vue-composable', url: 'https://github.com/Dan-Shields/nodecg-vue-composable'
icon: 'extension',
}, },
{ {
name: 'NodeCG', name: 'NodeCG',
role: 'Broadcast graphics framework', role: 'Broadcast graphics framework',
url: 'https://github.com/nodecg/nodecg', url: 'https://github.com/nodecg/nodecg'
icon: 'layers', }
},
]; ];
const techStack = [ const releaseLabel = computed(() => {
{ label: 'Vue 3', icon: 'hub' }, if (!latestRelease.value) {
{ label: 'Quasar', icon: 'style' }, return '';
{ label: 'TypeScript', icon: 'data_object' }, }
{ label: 'NodeCG', icon: 'layers' },
];
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> </script>
<template> <template>
<QPage class="q-pa-lg"> <QPage class="q-pa-lg">
<div class="q-mb-lg"> <div class="text-h4 q-mb-md">
<div class="text-h5 text-weight-medium"> {{ t('aboutTitle') }}
{{ t('aboutTitle') }}
</div>
</div> </div>
<QCard <div class="row q-col-gutter-lg">
flat <div class="col-12 col-md-6">
bordered <QCard
class="about-card" flat
> bordered
<!-- App identity --> >
<QCardSection class="q-pa-lg"> <QCardSection class="row items-center q-col-gutter-md">
<div class="row items-center q-gutter-md"> <div class="col-auto">
<QImg <QImg
src="../image.png" src="../image.png"
alt="Scoreko logo" alt="Scoreko logo"
class="app-logo" width="72px"
fit="contain" height="72px"
/> fit="contain"
<div> />
<div class="text-h6 text-weight-bold">
{{ appName }}
</div> </div>
<div class="row items-center q-gutter-xs q-mt-xs"> <div class="col">
<QBadge <div class="text-h6">
outline {{ appName }}
color="primary" </div>
class="version-badge" <div class="text-caption text-grey-7">
> {{ t('aboutVersion') }} {{ currentVersion }}
v{{ currentVersion }} </div>
</QBadge> </div>
</QCardSection>
<QSeparator />
<QCardSection>
<p class="q-mb-sm">
{{ t('aboutDescription') }}
</p>
<div class="column q-gutter-sm">
<QBtn <QBtn
:href="`${repoUrl}/releases`" href="https://github.com/nodecg/nodecg"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
icon="history" icon="open_in_new"
:label="t('aboutChangelog')" :label="t('aboutFrameworkNodeCG')"
color="grey-6" color="primary"
flat flat
dense
no-caps no-caps
size="xs" align="left"
/> />
</div> </div>
</div> </QCardSection>
</div>
</QCardSection>
<QSeparator /> <QSeparator />
<!-- Description + framework --> <QCardSection>
<QCardSection class="q-pa-lg"> <div class="text-subtitle2 q-mb-sm">
<p class="text-body2 text-grey-7 q-mb-md"> {{ t('aboutCollaboratorsTitle') }}
{{ t('aboutDescription') }} </div>
</p> <QList dense>
<QBtn <QItem
href="https://github.com/nodecg/nodecg" v-for="person in collaborators"
target="_blank" :key="person.name"
rel="noopener noreferrer" tag="a"
icon="open_in_new" :href="person.url"
:label="t('aboutFrameworkNodeCG')" target="_blank"
color="primary" rel="noopener noreferrer"
flat
dense
no-caps
/>
</QCardSection>
<QSeparator />
<!-- Tech stack -->
<QCardSection class="q-pa-lg">
<div class="text-overline text-grey-6 q-mb-sm">
{{ t('aboutTechStackTitle') }}
</div>
<div class="row q-gutter-xs">
<QChip
v-for="tech in techStack"
:key="tech.label"
:icon="tech.icon"
:label="tech.label"
color="primary"
text-color="white"
size="sm"
dense
/>
</div>
</QCardSection>
<QSeparator />
<!-- Collaborators -->
<QCardSection class="q-pa-lg">
<div class="text-overline text-grey-6 q-mb-sm">
{{ t('aboutCollaboratorsTitle') }}
</div>
<QList
dense
class="collaborators-list"
>
<QItem
v-for="person in collaborators"
:key="person.name"
tag="a"
:href="person.url"
target="_blank"
rel="noopener noreferrer"
class="collaborator-item rounded-borders"
>
<QItemSection avatar>
<QIcon
:name="person.icon"
size="18px"
color="primary"
class="collaborator-icon"
/>
</QItemSection>
<QItemSection>
<QItemLabel class="text-weight-medium">
{{ person.name }}
</QItemLabel>
<QItemLabel
caption
class="text-grey-6"
> >
{{ person.role }} <QItemSection>
</QItemLabel> <QItemLabel>{{ person.name }}</QItemLabel>
</QItemSection> <QItemLabel caption>
<QItemSection side> {{ person.role }}
<QIcon </QItemLabel>
name="arrow_forward_ios" </QItemSection>
size="12px" </QItem>
color="grey-5" </QList>
/> </QCardSection>
</QItemSection> </QCard>
</QItem> </div>
</QList>
</QCardSection>
<QSeparator /> <div class="col-12 col-md-6">
<QCard
flat
bordered
>
<QCardSection>
<div class="text-h6">
{{ t('aboutUpdateSystemTitle') }}
</div>
<div class="text-body2 text-grey-7 q-mt-xs">
{{ t('aboutUpdateSystemDescription') }}
</div>
</QCardSection>
<!-- Footer --> <QSeparator />
<QCardSection class="q-pa-md">
<div class="row items-center justify-between"> <QCardSection class="q-gutter-md">
<span class="text-caption text-grey-5"> <QBtn
© {{ currentYear }} Pandipipas · MIT License :label="t('aboutCheckUpdates')"
</span> color="primary"
<QBtn icon="sync"
:href="repoUrl" :loading="checkingUpdates"
target="_blank" no-caps
rel="noopener noreferrer" @click="checkForUpdates"
icon="open_in_new" />
label="GitHub"
color="grey-6" <QBanner
flat v-if="latestRelease"
dense rounded
no-caps class="bg-grey-2"
size="sm" >
/> <template #avatar>
</div> <QIcon
</QCardSection> :name="hasUpdate ? 'system_update_alt' : 'check_circle'"
</QCard> :color="hasUpdate ? 'warning' : 'positive'"
/>
</template>
<div class="text-subtitle2">
{{ t('aboutLatestRelease') }}: {{ releaseLabel }}
</div>
<div class="text-caption text-grey-7">
{{ t('aboutPublished') }}: {{ new Date(latestRelease.published_at).toLocaleString() }}
</div>
<div class="q-mt-sm">
{{ hasUpdate ? t('aboutUpdateAvailable') : t('aboutUpToDate') }}
</div>
<template #action>
<QBtn
flat
color="primary"
:label="t('aboutViewRelease')"
:href="releaseUrl"
target="_blank"
rel="noopener noreferrer"
no-caps
/>
</template>
</QBanner>
<QBanner
v-if="updateError"
rounded
class="bg-red-1 text-red-10"
>
{{ updateError }}
</QBanner>
<QBanner
rounded
class="bg-blue-1 text-blue-10"
>
{{ t('aboutElectronNote') }}
</QBanner>
</QCardSection>
</QCard>
</div>
</div>
</QPage> </QPage>
</template> </template>
<style scoped>
.about-card {
max-width: 520px;
}
.app-logo {
width: 56px;
height: 56px;
border-radius: 12px;
}
.version-badge {
font-size: 11px;
letter-spacing: 0.03em;
}
.collaborators-list {
margin: 0 -8px;
}
.collaborator-item {
border-radius: 8px;
transition: background 0.15s ease;
padding: 6px 8px;
text-decoration: none;
}
.collaborator-item:hover {
background: rgba(0, 0, 0, 0.04);
}
/* Dark mode hover fix */
.body--dark .collaborator-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.collaborator-icon {
opacity: 0.8;
}
</style>
@@ -11,10 +11,9 @@ useHead({ title: 'Dashboard' });
<template> <template>
<QPage class="q-pa-lg"> <QPage class="q-pa-lg">
<div class="dashboard-panels"> <div class="dashboard-panels q-mt-lg">
<div class="dashboard-row dashboard-row--scoreboard"> <div class="dashboard-row dashboard-row--scoreboard">
<QCard <QCard
flat
bordered bordered
class="dashboard-panel-card" class="dashboard-panel-card"
> >
@@ -26,7 +25,6 @@ useHead({ title: 'Dashboard' });
<div class="dashboard-row dashboard-row--bottom"> <div class="dashboard-row dashboard-row--bottom">
<QCard <QCard
flat
bordered bordered
class="dashboard-panel-card" class="dashboard-panel-card"
> >
@@ -35,7 +33,6 @@ useHead({ title: 'Dashboard' });
</QCardSection> </QCardSection>
</QCard> </QCard>
<QCard <QCard
flat
bordered bordered
class="dashboard-panel-card" class="dashboard-panel-card"
> >
@@ -56,12 +53,20 @@ useHead({ title: 'Dashboard' });
gap: 24px; gap: 24px;
} }
.dashboard-row {
width: 100%;
}
.dashboard-row--bottom { .dashboard-row--bottom {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 24px; gap: 24px;
} }
.dashboard-panel-card {
width: 100%;
}
.dashboard-panel-content { .dashboard-panel-content {
padding-bottom: 16px; padding-bottom: 16px;
} }
@@ -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>
+22 -42
View File
@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import bundlePackage from '../../../../package.json';
import { graphicsSettingsReplicant } from '../../../browser_shared/replicants'; import { graphicsSettingsReplicant } from '../../../browser_shared/replicants';
import { t } from '../i18n'; import { t } from '../i18n';
defineOptions({ name: 'GraphicsView' }); defineOptions({ name: 'GraphicsView' });
import bundlePackage from '../../../../package.json';
type GraphicConfig = { type GraphicConfig = {
name?: string; name?: string;
title?: string; title?: string;
@@ -129,29 +130,19 @@ const cards = computed<GraphicCard[]>(() => {
return result; return result;
}); });
const copiedCardId = ref<string | null>(null); const copyUrl = async (graphic: GraphicConfig) => {
const copyUrl = async (graphic: GraphicConfig, cardId: string) => {
const url = buildGraphicUrl(graphic); const url = buildGraphicUrl(graphic);
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
} 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; const input = document.createElement('input');
setTimeout(() => { input.value = url;
copiedCardId.value = null; document.body.appendChild(input);
}, 2000); input.select();
}; document.execCommand('copy');
document.body.removeChild(input);
const openUrl = (graphic: GraphicConfig) => {
window.open(buildGraphicUrl(graphic), '_blank');
}; };
const onDragStart = (event: DragEvent, graphic: GraphicConfig) => { const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
@@ -174,18 +165,16 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<template> <template>
<QPage class="q-pa-lg"> <QPage class="q-pa-lg">
<div class="q-mb-lg"> <div class="text-h4 q-mb-md">
<div class="text-h5 text-weight-medium"> {{ t('graphicsTitle') }}
{{ t('graphicsTitle') }} </div>
</div> <div class="text-body1 q-mb-lg">
<div class="text-body2 text-grey-7 q-mt-xs"> {{ t('graphicsDescription') }}
{{ t('graphicsDescription') }}
</div>
</div> </div>
<div <div
v-if="cards.length === 0" v-if="cards.length === 0"
class="text-body2 text-grey-6" class="text-body2 text-grey-5"
> >
{{ t('graphicsNoConfigured') }} {{ t('graphicsNoConfigured') }}
</div> </div>
@@ -205,7 +194,7 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<div class="text-h6"> <div class="text-h6">
{{ card.label }} {{ card.label }}
</div> </div>
<div class="text-caption text-grey-4"> <div class="text-caption text-grey-5">
{{ card.graphic.file }} {{ card.graphic.file }}
</div> </div>
</div> </div>
@@ -235,27 +224,18 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
<div class="row items-center q-gutter-sm"> <div class="row items-center q-gutter-sm">
<QBtn <QBtn
:color="copiedCardId === card.id ? 'positive' : 'primary'" color="primary"
:icon="copiedCardId === card.id ? 'check' : 'content_copy'" icon="content_copy"
no-caps :label="t('graphicsCopyUrl')"
:label="copiedCardId === card.id ? t('graphicsCopied') : t('graphicsCopyUrl')" @click="copyUrl(card.graphic)"
@click="copyUrl(card.graphic, card.id)"
/> />
<QBtn <QBtn
color="secondary" color="secondary"
icon="open_with" icon="open_with"
no-caps
draggable="true"
:label="t('graphicsDragObs')" :label="t('graphicsDragObs')"
draggable="true"
@dragstart="onDragStart($event, card.graphic)" @dragstart="onDragStart($event, card.graphic)"
/> />
<QBtn
color="grey-7"
icon="open_in_new"
no-caps
:label="t('graphicsOpenBrowser')"
@click="openUrl(card.graphic)"
/>
</div> </div>
</QCardSection> </QCardSection>
</QCard> </QCard>
File diff suppressed because it is too large Load Diff
+95 -428
View File
@@ -1,11 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { useHead } from '@unhead/vue'; import { useHead } from '@unhead/vue';
import { useQuasar } from 'quasar';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useIntegration } from '../composables/useIntegration';
import type { Locale } from '../i18n'; import type { Locale } from '../i18n';
import { locale, setLocale, t } from '../i18n'; import { locale, setLocale, t } from '../i18n';
import { usePlayersStore } from '../stores/players';
import { import {
eventToShortcut, eventToShortcut,
type ShortcutAction, type ShortcutAction,
@@ -14,10 +11,6 @@ import {
defineOptions({ name: 'SettingsView' }); defineOptions({ name: 'SettingsView' });
useHead(() => ({ title: t('settingsTitle') }));
// ─── Idioma ────────────────────────────────────────────────────────────────────
const languageOptions = computed(() => [ const languageOptions = computed(() => [
{ label: t('languageSpanish'), value: 'es' as const }, { label: t('languageSpanish'), value: 'es' as const },
{ label: t('languageEnglish'), value: 'en' as const }, { label: t('languageEnglish'), value: 'en' as const },
@@ -25,14 +18,13 @@ const languageOptions = computed(() => [
const selectedLanguage = computed<Locale>({ const selectedLanguage = computed<Locale>({
get: () => locale.value, get: () => locale.value,
set: (value) => { setLocale(value); }, set: (value) => {
setLocale(value);
},
}); });
// ─── Atajos de teclado ─────────────────────────────────────────────────────────
const shortcutSettingsStore = useShortcutSettingsStore(); const shortcutSettingsStore = useShortcutSettingsStore();
const recordingAction = ref<ShortcutAction | null>(null); const recordingAction = ref<ShortcutAction | null>(null);
const shortcutsContainerRef = ref<HTMLElement | null>(null);
const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: string }[]>(() => [ const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: string }[]>(() => [
{ action: 'leftIncrement', label: t('settingsShortcutLeftIncrementLabel'), hint: t('settingsShortcutLeftIncrementHint') }, { action: 'leftIncrement', label: t('settingsShortcutLeftIncrementLabel'), hint: t('settingsShortcutLeftIncrementHint') },
@@ -41,20 +33,6 @@ const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: s
{ action: 'rightDecrement', label: t('settingsShortcutRightDecrementLabel'), hint: t('settingsShortcutRightDecrementHint') }, { action: 'rightDecrement', label: t('settingsShortcutRightDecrementLabel'), hint: t('settingsShortcutRightDecrementHint') },
]); ]);
const conflictingActions = computed(() => {
const seen = new Map<string, ShortcutAction>();
const conflicts = new Set<ShortcutAction>();
for (const [action, shortcut] of Object.entries(shortcutSettingsStore.shortcuts) as [ShortcutAction, string][]) {
if (seen.has(shortcut)) {
conflicts.add(action);
conflicts.add(seen.get(shortcut)!);
} else {
seen.set(shortcut, action);
}
}
return conflicts;
});
const stopRecording = () => { const stopRecording = () => {
recordingAction.value = null; recordingAction.value = null;
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
@@ -63,443 +41,132 @@ const stopRecording = () => {
}; };
const onRecordKeydown = (event: KeyboardEvent) => { const onRecordKeydown = (event: KeyboardEvent) => {
if (!recordingAction.value) return; if (!recordingAction.value) {
if (event.key === 'Escape') { event.preventDefault(); stopRecording(); return; } return;
}
const shortcut = eventToShortcut(event); const shortcut = eventToShortcut(event);
if (!shortcut) return; if (!shortcut) {
return;
}
event.preventDefault(); event.preventDefault();
shortcutSettingsStore.setShortcut(recordingAction.value, shortcut); shortcutSettingsStore.setShortcut(recordingAction.value, shortcut);
stopRecording(); stopRecording();
}; };
const onDocumentMousedown = (event: MouseEvent) => {
if (recordingAction.value && shortcutsContainerRef.value && !shortcutsContainerRef.value.contains(event.target as Node)) {
stopRecording();
}
};
const startRecording = (action: ShortcutAction) => { const startRecording = (action: ShortcutAction) => {
if (recordingAction.value === action) { stopRecording(); return; } if (recordingAction.value === action) {
stopRecording();
return;
}
recordingAction.value = action; recordingAction.value = action;
if (typeof document !== 'undefined') document.body.dataset.shortcutRecording = 'true'; if (typeof document !== 'undefined') {
document.body.dataset.shortcutRecording = 'true';
}
}; };
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('keydown', onRecordKeydown); window.addEventListener('keydown', onRecordKeydown);
document.addEventListener('mousedown', onDocumentMousedown);
} }
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.removeEventListener('keydown', onRecordKeydown); window.removeEventListener('keydown', onRecordKeydown);
document.removeEventListener('mousedown', onDocumentMousedown);
} }
stopRecording(); stopRecording();
}); });
// ─── Integraciones ───────────────────────────────────────────────────────────── useHead(() => ({ title: t('settingsTitle') }));
const STARTGG_TOKEN_STORAGE_KEY = 'scoreko-dev.startgg-token';
const CHALLONGE_TOKEN_STORAGE_KEY = 'scoreko-dev.challonge-token';
const STARTGG_TEMP_PLAYERS_STORAGE_KEY = 'scoreko-dev.startgg-temp-players';
const CHALLONGE_TEMP_PLAYERS_STORAGE_KEY = 'scoreko-dev.challonge-temp-players';
const TEMP_FALLBACK_DURATION_SECONDS = 12 * 60 * 60;
const playersStore = usePlayersStore();
const $q = useQuasar();
const startgg = useIntegration({
messagePrefix: 'startgg',
providerLabel: 'start.gg',
tokenStorageKey: STARTGG_TOKEN_STORAGE_KEY,
tempPlayersStorageKey: STARTGG_TEMP_PLAYERS_STORAGE_KEY,
tempFallbackDurationSeconds: TEMP_FALLBACK_DURATION_SECONDS,
playersStore,
});
const challonge = useIntegration({
messagePrefix: 'challonge',
providerLabel: 'Challonge',
tokenStorageKey: CHALLONGE_TOKEN_STORAGE_KEY,
tempPlayersStorageKey: CHALLONGE_TEMP_PLAYERS_STORAGE_KEY,
tempFallbackDurationSeconds: TEMP_FALLBACK_DURATION_SECONDS,
on401Message:
'Challonge rejected the token (401 Unauthorized). Re-connect OAuth so it grants scopes (me, tournaments:read, participants:read) or paste a valid personal API token.',
playersStore,
});
// ─── Diálogos de token manual ──────────────────────────────────────────────────
const isStartggManualDialogOpen = ref(false);
const startggManualDraft = ref('');
const openStartggManualDialog = () => {
startggManualDraft.value = startgg.token;
isStartggManualDialogOpen.value = true;
};
const saveStartggManualToken = () => {
startgg.token = startggManualDraft.value.trim();
isStartggManualDialogOpen.value = false;
$q.notify({ type: 'positive', message: startgg.token ? 'start.gg token saved.' : 'start.gg token removed.' });
};
const isChallongeManualDialogOpen = ref(false);
const challongeManualDraft = ref('');
const openChallongeManualDialog = () => {
challongeManualDraft.value = challonge.token;
isChallongeManualDialogOpen.value = true;
};
const saveChallongeManualToken = () => {
challonge.token = challongeManualDraft.value.trim();
isChallongeManualDialogOpen.value = false;
$q.notify({ type: 'positive', message: challonge.token ? 'Challonge token saved.' : 'Challonge token removed.' });
};
// Label de estado de Challonge
const challongeConnectionLabel = computed(() =>
challonge.hasValidatedToken ? t('playersConnected') : 'Token set',
);
watch(() => startgg.importDialogError, (msg) => {
if (msg) $q.notify({ type: 'negative', message: msg });
});
watch(() => challonge.importDialogError, (msg) => {
if (msg) $q.notify({ type: 'negative', message: msg });
});
</script> </script>
<template> <template>
<QPage class="q-pa-lg"> <QPage class="q-pa-lg">
<div class="q-mb-lg"> <div class="text-h4 q-mb-md">
<div class="text-h5 text-weight-medium"> {{ t('settingsTitle') }}
{{ t('settingsTitle') }} </div>
</div> <div class="text-body1 q-mb-lg">
<div class="text-body2 text-grey-7 q-mt-xs"> {{ t('settingsDescription') }}
{{ t('settingsDescription') }}
</div>
</div> </div>
<div class="column q-gutter-lg settings-layout"> <QCard
flat
bordered
class="q-pa-md settings-card"
>
<QCardSection class="q-pa-none q-mb-lg">
<div class="text-subtitle1 q-mb-sm">
{{ t('settingsLanguageLabel') }}
</div>
<!-- Idioma --> <QSelect
<QCard flat bordered class="settings-card"> v-model="selectedLanguage"
<QCardSection class="q-pa-lg"> emit-value
<div class="text-overline text-grey-6 q-mb-md">{{ t('settingsLanguageLabel') }}</div> map-options
<QSelect :label="t('settingsLanguageLabel')"
v-model="selectedLanguage" :options="languageOptions"
emit-value />
map-options
:options="languageOptions" <div class="text-caption text-grey-5 q-mt-sm">
:label="t('settingsLanguageLabel')" {{ t('settingsLanguageHint') }}
outlined </div>
</QCardSection>
<QSeparator class="q-mb-lg" />
<QCardSection class="q-pa-none">
<div class="row items-center justify-between q-mb-sm">
<div class="text-subtitle1">
{{ t('settingsShortcutTitle') }}
</div>
<QBtn
round
dense dense
style="max-width: 280px" flat
/> color="primary"
<div class="text-caption text-grey-6 q-mt-sm"> icon="restart_alt"
{{ t('settingsLanguageHint') }} :aria-label="t('settingsShortcutReset')"
</div> @click="shortcutSettingsStore.resetShortcuts"
</QCardSection>
</QCard>
<!-- Integraciones -->
<QCard flat bordered class="settings-card">
<QCardSection class="q-pa-lg">
<div class="text-overline text-grey-6 q-mb-xs">{{ t('settingsIntegrationsTitle') || 'Integrations' }}</div>
<div class="text-caption text-grey-6 q-mb-lg">
{{ t('settingsIntegrationsDescription') || 'Connect your tournament platform accounts to import players directly from brackets.' }}
</div>
<div class="column q-gutter-md">
<!-- start.gg -->
<div class="integration-row">
<div class="integration-row__logo">
<svg style="width: 28px; height: 28px;" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<path d="M1.25 20h7.5A1.25 1.25 0 0 0 10 18.75v-7.5A1.25 1.25 0 0 1 11.25 10h27.5A1.25 1.25 0 0 0 40 8.75V1.25A1.25 1.25 0 0 0 38.75 0H10A10 10 0 0 0 0 10v8.75A1.25 1.25 0 0 0 1.25 20Z" fill="#3f80ff" />
<path d="M38.75 20h-7.5A1.25 1.25 0 0 0 30 21.25v7.5A1.25 1.25 0 0 1 28.75 30H1.25A1.25 1.25 0 0 0 0 31.25v7.5A1.25 1.25 0 0 0 1.25 40H30A10 10 0 0 0 40 30V21.25A1.25 1.25 0 0 0 38.75 20Z" fill="#ff2768" />
</svg>
</div>
<div class="integration-row__info">
<div class="text-body2 text-weight-medium">start.gg</div>
<div class="text-caption text-grey-6">{{ t('playersStartggHelp') }}</div>
</div>
<div class="integration-row__actions row q-gutter-sm items-center">
<QChip
v-if="startgg.hasTokenConfigured"
dense
:color="startgg.hasValidatedToken ? 'positive' : 'warning'"
text-color="white"
icon="check_circle"
>
{{ t('playersConnected') }}
</QChip>
<QBtn
v-if="!startgg.hasTokenConfigured"
color="primary"
icon="login"
no-caps
unelevated
:label="t('playersConnectStartgg')"
:loading="startgg.oauthLoading"
@click="startgg.connectWithOAuth"
/>
<QBtn
v-else
flat
color="negative"
icon="link_off"
no-caps
size="sm"
:label="t('settingsDisconnect') || 'Disconnect'"
@click="startggManualDraft = ''; startgg.token = ''; $q.notify({ type: 'info', message: 'start.gg disconnected.' })"
/>
<QBtn
outline
:color="startgg.hasTokenConfigured ? 'grey-5' : 'white'"
icon="vpn_key"
no-caps
size="sm"
:label="t('playersUsePersonalApi')"
@click="openStartggManualDialog"
/>
</div>
</div>
<QSeparator />
<!-- Challonge -->
<div class="integration-row">
<div class="integration-row__logo">
<img
src="https://challonge.com/favicon.ico"
alt="Challonge"
style="width: 28px; height: 28px; border-radius: 6px;"
>
</div>
<div class="integration-row__info">
<div class="text-body2 text-weight-medium">Challonge</div>
<div class="text-caption text-grey-6">{{ t('playersChallongeHelp') }}</div>
</div>
<div class="integration-row__actions row q-gutter-sm items-center">
<QChip
v-if="challonge.hasTokenConfigured"
dense
:color="challonge.hasValidatedToken ? 'positive' : 'warning'"
text-color="white"
icon="check_circle"
>
{{ challongeConnectionLabel }}
</QChip>
<QBtn
v-if="!challonge.hasTokenConfigured"
color="primary"
icon="login"
no-caps
unelevated
:label="t('playersConnectChallonge')"
:loading="challonge.oauthLoading"
@click="challonge.connectWithOAuth"
/>
<QBtn
v-else
flat
color="negative"
icon="link_off"
no-caps
size="sm"
:label="t('settingsDisconnect') || 'Disconnect'"
@click="challongeManualDraft = ''; challonge.token = ''; $q.notify({ type: 'info', message: 'Challonge disconnected.' })"
/>
<QBtn
outline
:color="challonge.hasTokenConfigured ? 'grey-5' : 'white'"
icon="vpn_key"
no-caps
size="sm"
:label="t('playersUsePersonalApi')"
@click="openChallongeManualDialog"
/>
</div>
</div>
</div>
</QCardSection>
</QCard>
<!-- Atajos de teclado -->
<QCard flat bordered class="settings-card">
<QCardSection class="q-pa-lg">
<div class="row items-center justify-between q-mb-xs">
<div class="text-overline text-grey-6">
{{ t('settingsShortcutTitle') }}
</div>
<QBtn
round dense flat color="primary" icon="restart_alt"
:aria-label="t('settingsShortcutReset')"
@click="shortcutSettingsStore.resetShortcuts"
>
<QTooltip>{{ t('settingsShortcutReset') }}</QTooltip>
</QBtn>
</div>
<div class="text-caption text-grey-6 q-mb-lg">
{{ t('settingsShortcutDescription') }}
</div>
<QBanner
v-if="conflictingActions.size > 0"
class="bg-warning text-white q-mb-md"
rounded dense
> >
<template #avatar> <QTooltip>{{ t('settingsShortcutReset') }}</QTooltip>
<QIcon name="warning" color="white" /> </QBtn>
</div>
<div class="text-caption text-grey-5 q-mb-md">
{{ t('settingsShortcutDescription') }}
</div>
<div class="column q-gutter-md">
<QInput
v-for="field in shortcutFields"
:key="field.action"
:model-value="shortcutSettingsStore.shortcuts[field.action]"
readonly
:label="field.label"
>
<template #append>
<QBtn
flat
round
dense
:icon="recordingAction === field.action ? 'stop_circle' : 'keyboard'"
:color="recordingAction === field.action ? 'negative' : 'primary'"
@click="startRecording(field.action)"
/>
</template> </template>
{{ t('settingsShortcutConflictWarning') }} <template #hint>
</QBanner> {{ recordingAction === field.action ? t('settingsShortcutRecordingHint') : field.hint }}
</template>
<div ref="shortcutsContainerRef" class="column q-gutter-md"> </QInput>
<QInput </div>
v-for="field in shortcutFields" </QCardSection>
:key="field.action" </QCard>
: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>
<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)"
/>
<QBtn
flat round dense icon="restart_alt" color="grey-5"
:aria-label="t('settingsShortcutResetSingle')"
@click="shortcutSettingsStore.resetShortcut(field.action)"
>
<QTooltip>{{ t('settingsShortcutResetSingle') }}</QTooltip>
</QBtn>
</template>
</QInput>
</div>
</QCardSection>
</QCard>
</div>
<!-- Diálogo token personal start.gg -->
<QDialog v-model="isStartggManualDialogOpen">
<QCard class="settings-dialog">
<QCardSection>
<div class="text-h6">Personal start.gg API token</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div class="text-body2 q-mb-sm">
If OAuth fails, you can create a personal token manually:
</div>
<ol class="q-pl-md q-mb-md settings-token-steps">
<li>Go to https://start.gg/admin/profile/developer</li>
<li>Sign in with your account</li>
<li>From the 3 access tokens, click <strong>Third Party</strong></li>
<li>Create a new one and fill the description with any name you want</li>
<li>Copy the generated token and paste it below</li>
</ol>
<QInput
v-model="startggManualDraft"
label="Paste your personal token"
dense outlined type="password"
/>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn flat no-caps label="Cancel" color="secondary" @click="isStartggManualDialogOpen = false" />
<QBtn flat no-caps color="negative" label="Delete token" @click="startggManualDraft = ''; saveStartggManualToken()" />
<QBtn no-caps color="primary" label="Save token" @click="saveStartggManualToken" />
</QCardActions>
</QCard>
</QDialog>
<!-- Diálogo token personal Challonge -->
<QDialog v-model="isChallongeManualDialogOpen">
<QCard class="settings-dialog">
<QCardSection>
<div class="text-h6">Personal Challonge API token</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div class="text-body2 q-mb-sm">
If OAuth fails, paste a personal Challonge API token.
</div>
<QInput
v-model="challongeManualDraft"
label="Paste your personal Challonge token"
dense outlined type="password"
/>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn flat no-caps label="Cancel" color="secondary" @click="isChallongeManualDialogOpen = false" />
<QBtn flat no-caps color="negative" label="Delete token" @click="challongeManualDraft = ''; saveChallongeManualToken()" />
<QBtn no-caps color="primary" label="Save token" @click="saveChallongeManualToken" />
</QCardActions>
</QCard>
</QDialog>
</QPage> </QPage>
</template> </template>
<style scoped> <style scoped>
.settings-layout {
max-width: 680px;
}
.settings-card { .settings-card {
width: 100%; max-width: 720px;
}
.settings-dialog {
min-width: 320px;
width: min(560px, 90vw);
}
.settings-token-steps {
line-height: 1.6;
}
/* Fila de integración: logo | info | acciones */
.integration-row {
display: flex;
align-items: center;
gap: 16px;
}
.integration-row__logo {
flex-shrink: 0;
width: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.integration-row__info {
flex: 1 1 auto;
min-width: 0;
}
.integration-row__actions {
flex-shrink: 0;
} }
</style> </style>
+331 -274
View File
@@ -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 { 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_API_BASE = 'https://api.challonge.com/v2.1';
const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize'; const CHALLONGE_OAUTH_AUTHORIZE_ENDPOINT = 'https://api.challonge.com/oauth/authorize';
@@ -18,17 +17,21 @@ const CHALLONGE_OAUTH_SCOPES = [
const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback'; const CHALLONGE_OAUTH_CALLBACK_PATH = '/challonge/callback';
const CHALLONGE_OAUTH_DEFAULT_PORT = 34921; const CHALLONGE_OAUTH_DEFAULT_PORT = 34921;
const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 20;
// ─── URL del proxy OAuth ─────────────────────────────────────────────────────── interface OAuthConfig {
// Rellena esta constante con la URL de tu Cloudflare Worker tras el deploy. clientId: string;
// Formato: 'https://scoreko-oauth-proxy.TU-SUBDOMINIO.workers.dev' clientSecret: string;
// callbackPort: number;
// También puedes sobreescribirla en cfg/scoreko.json con "oauthProxyUrl" }
// (útil para apuntar a un entorno de staging sin recompilar).
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
// ─── Tipos ───────────────────────────────────────────────────────────────────── interface OAuthSession {
sessionId: string;
state: string;
expiresAt: number;
status: 'pending' | 'completed' | 'error' | 'expired';
token?: string;
error?: string;
}
interface OAuthTokenResponse { interface OAuthTokenResponse {
access_token?: string; access_token?: string;
@@ -54,154 +57,157 @@ interface ImportedPlayer {
twitter: string; twitter: string;
} }
// ─── Modo OAuth ──────────────────────────────────────────────────────────────── const oauthSessions = new Map<string, OAuthSession>();
// let oauthCallbackServer: Server | null = null;
// DEV: cfg/scoreko.json tiene challongeClientId + challongeClientSecret.
// El exchange se hace directamente contra Challonge.
//
// PROXY: No hay credenciales en la config local.
// El clientId se obtiene del Worker (es público, no secreto).
// El exchange lo hace el Worker, que guarda el clientSecret en sus env vars.
type OAuthMode = const getStringProp = (payload: unknown, key: string): string => {
| { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number } if (typeof payload !== 'object' || payload === null || !(key in payload)) {
| { type: 'proxy'; proxyBaseUrl: string; callbackPort: number }; return '';
const getOAuthMode = (): OAuthMode => {
const bundleConfig = nodecg.bundleConfig as Record<string, unknown>;
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 proxyBaseUrl =
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
if (clientId && clientSecret) {
nodecg.log.info('[Challonge] OAuth: modo dev (credenciales locales)');
return { type: 'dev', clientId, clientSecret, callbackPort };
} }
nodecg.log.info(`[Challonge] OAuth: modo proxy → ${proxyBaseUrl}`); const value = (payload as Record<string, unknown>)[key];
return { type: 'proxy', proxyBaseUrl, callbackPort }; return typeof value === 'string' ? value.trim() : String(value || '').trim();
}; };
// ─── Exchange de token ───────────────────────────────────────────────────────── 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;
};
/** Modo dev: exchange directo con Challonge usando credenciales locales */ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
const exchangeCodeDirectly = async ( 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 rawPort = Number(bundleConfig.challongeOAuthPort ?? CHALLONGE_OAUTH_DEFAULT_PORT);
const callbackPort = Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT;
if (!clientId || !clientSecret) {
return null;
}
return {
clientId,
clientSecret,
callbackPort,
};
};
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, code: string,
redirectUri: string, redirectUri: string,
clientId: string, oauthConfig: OAuthConfig,
clientSecret: string,
): Promise<string> => { ): Promise<string> => {
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
client_id: clientId, client_id: oauthConfig.clientId,
client_secret: clientSecret, client_secret: oauthConfig.clientSecret,
redirect_uri: redirectUri, redirect_uri: redirectUri,
}); });
const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, { const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(), body: params.toString(),
}); });
const rawBody = await response.text(); const payload = await parseOAuthTokenPayload(response);
let payload: OAuthTokenResponse;
try {
payload = JSON.parse(rawBody) as OAuthTokenResponse;
} catch {
payload = { message: rawBody };
}
if (!response.ok) { if (!response.ok) {
throw new Error( throw new Error(payload.error_description || payload.error || payload.message || `OAuth token request failed (${response.status})`);
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) { if (!token) {
throw new Error( throw new Error(payload.error_description || payload.error || payload.message || 'OAuth token response did not include an access token');
payload.error_description ??
payload.error ??
payload.message ??
'OAuth token response did not include an access token',
);
} }
return token; return token;
}; };
/** Modo proxy: el Worker hace el exchange; el clientSecret nunca sale del Worker */
const exchangeCodeViaProxy = async (
code: string,
redirectUri: string,
proxyBaseUrl: string,
): Promise<string> => {
const response = await fetch(`${proxyBaseUrl}/oauth/challonge/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, redirectUri }),
});
const rawBody = await response.text();
let payload: { access_token?: string; error?: string };
try {
payload = JSON.parse(rawBody) as typeof payload;
} catch {
payload = { error: rawBody };
}
if (!response.ok) {
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
}
const token = String(payload.access_token ?? '').trim();
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
return token;
};
/**
* Callback que recibe oauth-server.ts cuando llega el código de autorización.
* Delega al modo correcto; _config no se usa porque el modo ya está determinado.
*/
const exchangeOAuthCodeForToken = async (
code: string,
redirectUri: string,
_config: OAuthConfig,
): Promise<string> => {
const mode = getOAuthMode();
if (mode.type === 'dev') {
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
}
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
};
// ─── 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 parseJsonResponse = async (response: Response): Promise<unknown> => {
const rawBody = await response.text(); const rawBody = await response.text();
if (!rawBody) return null; if (!rawBody) {
return null;
}
try { try {
return JSON.parse(rawBody) as unknown; return JSON.parse(rawBody) as unknown;
} catch { } catch {
@@ -212,7 +218,6 @@ const parseJsonResponse = async (response: Response): Promise<unknown> => {
const requestChallonge = async (path: string, token: string): Promise<unknown> => { const requestChallonge = async (path: string, token: string): Promise<unknown> => {
const requestUrl = `${CHALLONGE_API_BASE}${path}`; const requestUrl = `${CHALLONGE_API_BASE}${path}`;
// ── Intento v2 (OAuth Bearer) ─────────────────────────────────────────────
const v2Response = await fetch(requestUrl, { const v2Response = await fetch(requestUrl, {
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@@ -221,13 +226,14 @@ const requestChallonge = async (path: string, token: string): Promise<unknown> =
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
}); });
const v2Payload = await parseJsonResponse(v2Response); const v2Payload = await parseJsonResponse(v2Response);
if (v2Response.ok) { if (v2Response.ok) {
return v2Payload; return v2Payload;
} }
// ── Fallback v1 (API key personal pegada manualmente) ───────────────────── // Fallback for personal API keys pasted manually (v1 auth style).
if (v2Response.status === 401) { if (v2Response.status === 401) {
const v1Response = await fetch(requestUrl, { const v1Response = await fetch(requestUrl, {
headers: { headers: {
@@ -237,68 +243,46 @@ const requestChallonge = async (path: string, token: string): Promise<unknown> =
Authorization: token, Authorization: token,
}, },
}); });
const v1Payload = await parseJsonResponse(v1Response);
const v1Payload = await parseJsonResponse(v1Response);
if (v1Response.ok) { if (v1Response.ok) {
return v1Payload; return v1Payload;
} }
}
const v1Error = v1Payload as ChallongeErrorPayload; const maybeError = v2Payload as { errors?: { detail?: string }; error?: string } | null;
if (!v2Response.ok) {
throw new Error( throw new Error(
v1Error?.errors?.detail ?? maybeError?.errors?.detail || maybeError?.error || `Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(),
v1Error?.error ??
`Challonge responded with ${v1Response.status} ${v1Response.statusText}`.trim(),
); );
} }
// ── Otros errores v2 (4xx/5xx que no sean 401) ──────────────────────────── return v2Payload;
const v2Error = v2Payload as ChallongeErrorPayload;
throw new Error(
v2Error?.errors?.detail ??
v2Error?.error ??
`Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(),
);
}; };
// ─── Parsers de respuesta ──────────────────────────────────────────────────────
const normalizeTournamentSlug = (value: string): string => { const normalizeTournamentSlug = (value: string): string => {
const trimmed = value.trim(); const trimmed = value.trim();
if (!trimmed) return ''; if (!trimmed) {
return trimmed return '';
.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;
}
} }
return null; return trimmed.replace(/^https?:\/\/[^/]+\//i, '').replace(/^tournaments\//i, '').replace(/^\/+/, '');
}; };
const parseRecentTournaments = (payload: unknown): RecentTournament[] => { const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
const rows: RecentTournament[] = []; const rows: RecentTournament[] = [];
const push = (candidate: Record<string, unknown>) => { const push = (candidate: Record<string, unknown>) => {
const attributes = const attributes = (typeof candidate.attributes === 'object' && candidate.attributes !== null)
typeof candidate.attributes === 'object' && candidate.attributes !== null ? (candidate.attributes as Record<string, unknown>)
? (candidate.attributes as Record<string, unknown>) : candidate;
: candidate;
const id = String(candidate.id ?? attributes.id ?? attributes.tournament_id ?? '').trim(); const id = String(candidate.id || attributes.id || attributes.tournament_id || '').trim();
const name = String(attributes.name ?? attributes.full_name ?? '').trim(); const name = String(attributes.name || attributes.full_name || '').trim();
const slug = normalizeTournamentSlug( const slug = normalizeTournamentSlug(String(attributes.url || attributes.slug || attributes.identifier || id));
String(attributes.url ?? attributes.slug ?? attributes.identifier ?? id),
);
if (!id || !name || !slug) return; if (!id || !name || !slug) {
return;
}
rows.push({ rows.push({
id, id,
@@ -310,25 +294,26 @@ const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
}; };
if (Array.isArray(payload)) { if (Array.isArray(payload)) {
for (const row of payload) { payload.forEach((row) => {
const wrapper = row as Record<string, unknown>; const wrapper = row as Record<string, unknown>;
const tournament = const tournament = (typeof wrapper.tournament === 'object' && wrapper.tournament !== null)
typeof wrapper.tournament === 'object' && wrapper.tournament !== null ? (wrapper.tournament as Record<string, unknown>)
? (wrapper.tournament as Record<string, unknown>) : wrapper;
: wrapper;
push(tournament); push(tournament);
} });
return rows; return rows;
} }
if (typeof payload === 'object' && payload !== null) { 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)) { if (Array.isArray(data)) {
for (const row of data) { data.forEach((row) => {
if (typeof row === 'object' && row !== null) { if (typeof row === 'object' && row !== null) {
push(row as Record<string, unknown>); push(row as Record<string, unknown>);
} }
} });
return rows;
} }
} }
@@ -339,137 +324,201 @@ const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
const map = new Map<string, ImportedPlayer>(); const map = new Map<string, ImportedPlayer>();
const push = (candidate: Record<string, unknown>) => { const push = (candidate: Record<string, unknown>) => {
const attributes = const attributes = (typeof candidate.attributes === 'object' && candidate.attributes !== null)
typeof candidate.attributes === 'object' && candidate.attributes !== null ? (candidate.attributes as Record<string, unknown>)
? (candidate.attributes as Record<string, unknown>) : candidate;
: candidate;
const id = String( const id = String(candidate.id || attributes.id || attributes.participant_id || '').trim();
candidate.id ?? attributes.id ?? attributes.participant_id ?? '', const gamertag = String(
attributes.display_name
|| attributes.name
|| attributes.username
|| attributes.gamer_tag
|| '',
).trim(); ).trim();
const rawDisplayName = String( if (!id || !gamertag) {
attributes.display_name ?? return;
attributes.name ?? }
attributes.username ??
attributes.gamer_tag ??
'',
).trim();
if (!id || !rawDisplayName) return;
const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/;
const pipeMatch = PIPE_PATTERN.exec(rawDisplayName);
const teamFromName = pipeMatch ? pipeMatch[1].trim() : '';
const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName;
const team = String(attributes.team_name ?? '').trim() || teamFromName;
map.set(id, { map.set(id, {
id, id,
gamertag, gamertag,
name: '', name: gamertag,
team, team: String(attributes.group_player_ids || attributes.team_name || '').trim(),
country: '', country: '',
twitter: String(attributes.twitter_handle ?? attributes.twitter ?? '').trim(), twitter: String(attributes.twitter_handle || attributes.twitter || '').trim(),
}); });
}; };
if (Array.isArray(payload)) { if (Array.isArray(payload)) {
for (const row of payload) { payload.forEach((row) => {
const wrapper = row as Record<string, unknown>; const wrapper = row as Record<string, unknown>;
const participant = const participant = (typeof wrapper.participant === 'object' && wrapper.participant !== null)
typeof wrapper.participant === 'object' && wrapper.participant !== null ? (wrapper.participant as Record<string, unknown>)
? (wrapper.participant as Record<string, unknown>) : wrapper;
: wrapper;
push(participant); push(participant);
} });
return Array.from(map.values()); return Array.from(map.values());
} }
if (typeof payload === 'object' && payload !== null) { 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)) { if (Array.isArray(data)) {
for (const row of data) { data.forEach((row) => {
if (typeof row === 'object' && row !== null) { if (typeof row === 'object' && row !== null) {
push(row as Record<string, unknown>); push(row as Record<string, unknown>);
} }
} });
} }
} }
return Array.from(map.values()); return Array.from(map.values());
}; };
// ─── Utilidades ──────────────────────────────────────────────────────────────── const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => {
if (oauthCallbackServer) {
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);
};
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
const mode = getOAuthMode();
let serverConfig: OAuthConfig;
if (mode.type === 'dev') {
serverConfig = {
clientId: mode.clientId,
callbackPort: mode.callbackPort,
};
} else {
// Modo proxy: el clientId viene del Worker (es público, no secreto)
try {
const res = await fetch(`${mode.proxyBaseUrl}/oauth/challonge/client-id`);
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
const data = await res.json() as { clientId?: string };
const clientId = String(data.clientId ?? '').trim();
if (!clientId) throw new Error('Proxy did not return a clientId');
serverConfig = { clientId, callbackPort: mode.callbackPort };
} catch (err) {
sendAck(
ack,
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
);
return;
}
}
try {
await oauthServer.ensureServer(serverConfig);
} catch (err) {
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
return; return;
} }
sendAck(ack, null, oauthServer.createSession(serverConfig)); 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;
};
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
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 ensureOAuthCallbackServer(oauthConfig);
} catch (serverError) {
const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback';
sendAck(ack, message);
return;
}
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) => { nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
cleanupExpiredOAuthSessions();
const sessionId = getStringProp(payload, 'sessionId'); const sessionId = getStringProp(payload, 'sessionId');
if (!sessionId) { if (!sessionId) {
sendAck(ack, 'Missing OAuth session id'); sendAck(ack, 'Missing OAuth session id');
return; return;
} }
const status = oauthServer.getSessionStatus(sessionId); const session = oauthSessions.get(sessionId);
if (!status) { if (!session) {
sendAck(ack, 'OAuth session not found'); sendAck(ack, 'OAuth session not found');
return; 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) => { nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ack) => {
const token = getStringProp(payload, 'token'); const token = getStringProp(payload, 'token');
if (!token) { if (!token) {
sendAck(ack, 'Missing Challonge API token'); sendAck(ack, 'Missing Challonge API token');
return; return;
@@ -479,10 +528,12 @@ nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ac
const raw = await requestChallonge('/tournaments.json', token); const raw = await requestChallonge('/tournaments.json', token);
const tournaments = parseRecentTournaments(raw) const tournaments = parseRecentTournaments(raw)
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0)) .sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
.slice(0, RECENT_TOURNAMENTS_LIMIT); .slice(0, 20);
sendAck(ack, null, tournaments); sendAck(ack, null, tournaments);
} catch (error) { } 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);
} }
}); });
@@ -490,16 +541,22 @@ nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ac
const token = getStringProp(payload, 'token'); const token = getStringProp(payload, 'token');
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug')); const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
if (!token) { sendAck(ack, 'Missing Challonge API token'); return; } if (!token) {
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; } sendAck(ack, 'Missing Challonge API token');
return;
}
if (!slug) {
sendAck(ack, 'Missing tournament slug');
return;
}
try { try {
const raw = await requestChallonge( const raw = await requestChallonge(`/tournaments/${encodeURIComponent(slug)}/participants.json`, token);
`/tournaments/${encodeURIComponent(slug)}/participants.json`, const players = parseImportedPlayers(raw);
token, sendAck(ack, null, players);
);
sendAck(ack, null, parseImportedPlayers(raw));
} catch (error) { } 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);
} }
}); });
+513
View File
@@ -0,0 +1,513 @@
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { nodecg } from './util/nodecg.js';
const CHARACTER_NAMES_FILE = 'fighting-characters.json';
const LOCAL_MANIFEST_FILE = 'manifest.json';
const GAME_TITLES_FILE = 'games.json';
const CACHED_GAME_TITLES_FILE = 'games-cache.json';
type RemoteGame = {
title: string;
slug: string;
repoFolder: string;
logoFile: string;
};
type AssetFileEntry = {
path: string;
size: number;
downloadUrl: string;
};
type HttpManifestEntry = string | {
path?: unknown;
size?: unknown;
url?: unknown;
};
type HttpGameTitleEntry = {
slug?: unknown;
title?: unknown;
};
type HttpGameTitlesFile = Record<string, unknown> | HttpGameTitleEntry[];
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const bundleRoot = path.resolve(extensionDir, '..');
const legacyAssetsRoot = path.join(bundleRoot, 'game-assets');
const assetsRoot = path.join(bundleRoot, 'db', `${nodecg.bundleName}-game-assets`);
let assetsStorageReady = false;
const ensureAssetsStorageReady = async () => {
if (assetsStorageReady) {
return;
}
await mkdir(path.dirname(assetsRoot), { recursive: true });
const [currentStats, legacyStats] = await Promise.all([
stat(assetsRoot).catch(() => null),
stat(legacyAssetsRoot).catch(() => null),
]);
if (!currentStats && legacyStats?.isDirectory()) {
await rename(legacyAssetsRoot, assetsRoot).catch(async () => {
await mkdir(assetsRoot, { recursive: true });
});
} else {
await mkdir(assetsRoot, { recursive: true });
}
assetsStorageReady = true;
};
void ensureAssetsStorageReady();
const assetsRouter = nodecg.Router();
assetsRouter.get('/*', async (req, res) => {
const wildcardParam = (req.params as Record<string, unknown>)['0']
?? (req.params as Record<string, unknown>)[''];
const requestedPath = Array.isArray(wildcardParam)
? String(wildcardParam[0] ?? '')
: typeof wildcardParam === 'string'
? wildcardParam
: '';
const normalizedPath = path.normalize(requestedPath).replace(/^(\.\.(?:[\\/]|$))+/, '');
const filePath = path.resolve(assetsRoot, normalizedPath);
if (!filePath.startsWith(assetsRoot)) {
res.status(400).send('Invalid asset path.');
return;
}
try {
const fileStats = await stat(filePath);
if (!fileStats.isFile()) {
res.status(404).send('Asset not found.');
return;
}
res.type(path.extname(filePath));
res.send(await readFile(filePath));
} catch {
res.status(404).send('Asset not found.');
}
});
nodecg.mount(`/bundles/${nodecg.bundleName}/game-assets`, assetsRouter);
const requestHeaders = {
'User-Agent': 'scoreko-dev-nodecg-bundle',
};
const getConfiguredAssetsBaseUrl = () => {
const configuredValue = nodecg.bundleConfig.assetsBaseUrl;
if (typeof configuredValue !== 'string') {
return 'http://localhost';
}
const trimmed = configuredValue.trim();
if (!trimmed) {
return 'http://localhost';
}
return trimmed.replace(/\/+$/, '');
};
const emitProgress = (title: string, progress: number, status: 'downloading' | 'completed' | 'error') => {
nodecg.sendMessage('scoreko-assets:downloadProgress', {
title,
progress: Math.max(0, Math.min(100, progress)),
status,
});
};
const fetchJson = async <T>(url: string): Promise<T> => {
const response = await fetch(url, { headers: requestHeaders });
if (!response.ok) {
throw new Error(`Error HTTP (${response.status}) al solicitar ${url}`);
}
return response.json() as Promise<T>;
};
const normalizeManifestEntry = (entry: HttpManifestEntry, gameTitle: string) => {
if (typeof entry === 'string') {
return {
path: entry,
size: 0,
explicitUrl: null,
};
}
if (typeof entry === 'object' && entry !== null && typeof entry.path === 'string') {
return {
path: entry.path,
size: typeof entry.size === 'number' ? entry.size : 0,
explicitUrl: typeof entry.url === 'string' ? entry.url : null,
};
}
throw new Error(`El ${LOCAL_MANIFEST_FILE} de ${gameTitle} contiene entradas inválidas.`);
};
const titleFromSlug = (slug: string) => slug
.split('-')
.filter(Boolean)
.map((segment) => segment[0].toUpperCase() + segment.slice(1))
.join(' ');
const parseGameTitlesMap = (payload: unknown): Map<string, string> => {
const map = new Map<string, string>();
if (Array.isArray(payload)) {
for (const entry of payload) {
const parsedEntry = entry as HttpGameTitleEntry;
if (
typeof entry === 'object'
&& entry !== null
&& typeof parsedEntry.slug === 'string'
&& typeof parsedEntry.title === 'string'
) {
const slug = parsedEntry.slug.trim();
const title = parsedEntry.title.trim();
if (slug && title) {
map.set(slug, title);
}
}
}
return map;
}
if (typeof payload === 'object' && payload !== null) {
for (const [slug, value] of Object.entries(payload)) {
if (typeof value !== 'string') {
continue;
}
const normalizedSlug = slug.trim();
const title = value.trim();
if (normalizedSlug && title) {
map.set(normalizedSlug, title);
}
}
}
return map;
};
const fetchCustomGameTitles = async (): Promise<Map<string, string>> => {
const baseUrl = getConfiguredAssetsBaseUrl();
const url = `${baseUrl}/games/${GAME_TITLES_FILE}`;
try {
const payload = await fetchJson<HttpGameTitlesFile>(url);
return parseGameTitlesMap(payload);
} catch {
return new Map<string, string>();
}
};
const loadCachedGameTitles = async (): Promise<Map<string, string>> => {
await ensureAssetsStorageReady();
const cachePath = path.join(assetsRoot, CACHED_GAME_TITLES_FILE);
try {
const raw = await readFile(cachePath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
return parseGameTitlesMap(parsed);
} catch {
return new Map<string, string>();
}
};
const saveCachedGameTitles = async (titles: Map<string, string>) => {
await ensureAssetsStorageReady();
const cachePath = path.join(assetsRoot, CACHED_GAME_TITLES_FILE);
const payload = Object.fromEntries([...titles.entries()].sort((left, right) => left[0].localeCompare(right[0])));
await writeFile(cachePath, JSON.stringify(payload, null, 2));
};
const listRemoteGames = async (): Promise<RemoteGame[]> => {
const baseUrl = getConfiguredAssetsBaseUrl();
const gamesIndexUrl = `${baseUrl}/games/`;
const customTitles = await fetchCustomGameTitles();
const response = await fetch(gamesIndexUrl, { headers: requestHeaders });
if (!response.ok) {
throw new Error(`Error HTTP (${response.status}) al solicitar ${gamesIndexUrl}`);
}
const html = await response.text();
const hrefMatches = [...html.matchAll(/href=["']([^"']+)["']/gi)].map((match) => match[1]);
const slugs = hrefMatches
.map((href) => {
const withoutQuery = href.split('?')[0]?.split('#')[0] ?? '';
if (!withoutQuery.endsWith('/')) {
return null;
}
const decoded = decodeURIComponent(withoutQuery);
const trimmed = decoded.replace(/^\/+|\/+$/g, '');
if (!trimmed || trimmed.includes('/') || trimmed === '.' || trimmed === '..') {
return null;
}
return trimmed;
})
.filter((slug): slug is string => slug !== null);
const uniqueSlugs = [...new Set(slugs)].sort((left, right) => left.localeCompare(right));
return uniqueSlugs.map((slug) => ({
slug,
repoFolder: slug,
title: customTitles.get(slug) ?? titleFromSlug(slug),
logoFile: `${slug}.png`,
}));
};
const listHttpFiles = async (game: RemoteGame): Promise<AssetFileEntry[]> => {
const baseUrl = getConfiguredAssetsBaseUrl();
const manifestUrl = `${baseUrl}/games/${game.repoFolder}/${LOCAL_MANIFEST_FILE}`;
const entries = await fetchJson<HttpManifestEntry[]>(manifestUrl);
if (!Array.isArray(entries) || entries.length === 0) {
throw new Error(`No se encontraron archivos en ${manifestUrl}.`);
}
return entries.map((rawEntry) => {
const normalized = normalizeManifestEntry(rawEntry, game.title);
const cleanPath = normalized.path.replace(/^\/+/, '');
return {
path: `games/${game.repoFolder}/${cleanPath}`,
size: Math.max(0, normalized.size),
downloadUrl: normalized.explicitUrl ?? `${baseUrl}/games/${game.repoFolder}/${cleanPath}`,
};
});
};
const listInstalledGames = async () => {
await ensureAssetsStorageReady();
const entries = await readdir(assetsRoot, { withFileTypes: true }).catch(() => []);
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort((left, right) => left.localeCompare(right));
};
const listInstalledGamesAsRemote = async (): Promise<RemoteGame[]> => {
const installedGames = await listInstalledGames();
const cachedTitles = await loadCachedGameTitles();
return installedGames.map((slug) => ({
slug,
repoFolder: slug,
title: cachedTitles.get(slug) ?? titleFromSlug(slug),
logoFile: `${slug}.png`,
}));
};
const parseCharacterNames = (content: string, gameTitle: string) => {
const parsed = JSON.parse(content) as unknown;
const names = Array.isArray(parsed)
? parsed
: typeof parsed === 'object' && parsed !== null && Array.isArray((parsed as { characters?: unknown }).characters)
? (parsed as { characters: unknown[] }).characters
: null;
if (!names || names.some((name) => typeof name !== 'string')) {
throw new Error(`El archivo ${CHARACTER_NAMES_FILE} de ${gameTitle} no tiene un formato válido.`);
}
return names;
};
const listInstalledCharacterNamesByGame = async () => {
const installedGames = await listInstalledGames();
const charactersByGame = await Promise.all(installedGames.map(async (slug) => {
const sourcePath = path.join(assetsRoot, slug, CHARACTER_NAMES_FILE);
try {
const fileContent = await readFile(sourcePath, 'utf8');
const names = parseCharacterNames(fileContent, slug);
return [slug, names] as const;
} catch {
return [slug, []] as const;
}
}));
return Object.fromEntries(charactersByGame) as Record<string, string[]>;
};
const downloadGameAssets = async (gameSlug: string) => {
await ensureAssetsStorageReady();
const customTitles = await fetchCustomGameTitles();
const game: RemoteGame = {
slug: gameSlug,
repoFolder: gameSlug,
title: customTitles.get(gameSlug) ?? titleFromSlug(gameSlug),
logoFile: `${gameSlug}.png`,
};
emitProgress(game.slug, 0, 'downloading');
const files = await listHttpFiles(game);
if (!files.length) {
throw new Error(`No se encontraron archivos para ${game.title}.`);
}
const hasCharacterNamesFile = files.some((file) => file.path.endsWith(`/${CHARACTER_NAMES_FILE}`));
if (!hasCharacterNamesFile) {
throw new Error(`No se encontró ${CHARACTER_NAMES_FILE} para ${game.title}.`);
}
const totalBytes = files.reduce((acc, file) => acc + (file.size || 0), 0);
let downloadedBytes = 0;
const destinationFolder = path.join(assetsRoot, game.slug);
await rm(destinationFolder, { recursive: true, force: true });
for (const file of files) {
const relativePath = file.path.replace(`games/${game.repoFolder}/`, '');
const targetPath = path.join(destinationFolder, relativePath);
await mkdir(path.dirname(targetPath), { recursive: true });
const response = await fetch(file.downloadUrl, { headers: requestHeaders });
if (!response.ok) {
throw new Error(`No se pudo descargar ${file.path} (status ${response.status}).`);
}
const arrayBuffer = await response.arrayBuffer();
await writeFile(targetPath, Buffer.from(arrayBuffer));
downloadedBytes += file.size || 0;
const progress = totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 100;
emitProgress(game.slug, progress, 'downloading');
}
emitProgress(game.slug, 100, 'completed');
return {
title: game.title,
slug: game.slug,
};
};
const removeGameAssets = async (gameSlug: string) => {
await ensureAssetsStorageReady();
const customTitles = await fetchCustomGameTitles();
const destinationFolder = path.join(assetsRoot, gameSlug);
await rm(destinationFolder, { recursive: true, force: true });
return {
title: customTitles.get(gameSlug) ?? titleFromSlug(gameSlug),
slug: gameSlug,
};
};
nodecg.listenFor('scoreko-assets:listRemoteGames', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
const remoteGames = await listRemoteGames();
const titlesToCache = new Map<string, string>();
remoteGames.forEach((game) => {
titlesToCache.set(game.slug, game.title);
});
await saveCachedGameTitles(titlesToCache);
ack(null, remoteGames);
} catch (error) {
try {
const installedGames = await listInstalledGamesAsRemote();
if (installedGames.length > 0) {
ack(null, installedGames);
return;
}
} catch {
// noop
}
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:listInstalled', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
ack(null, await listInstalledGames());
} catch (error) {
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:listCharactersByGame', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
ack(null, await listInstalledCharacterNamesByGame());
} catch (error) {
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:getAssetsBaseUrl', async (_payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
ack(null, { assetsBaseUrl: getConfiguredAssetsBaseUrl() });
} catch (error) {
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:downloadGame', async (payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
const slug = typeof payload === 'object' && payload !== null ? (payload as { slug?: unknown }).slug : undefined;
if (typeof slug !== 'string') {
throw new Error('Slug de juego inválido.');
}
const downloaded = await downloadGameAssets(slug);
ack(null, {
downloaded,
installedGames: await listInstalledGames(),
});
} catch (error) {
if (typeof payload === 'object' && payload !== null && typeof (payload as { slug?: unknown }).slug === 'string') {
emitProgress((payload as { slug: string }).slug, 0, 'error');
}
ack((error as Error).message);
}
});
nodecg.listenFor('scoreko-assets:removeGame', async (payload: unknown, ack) => {
if (typeof ack !== 'function') {
return;
}
try {
const slug = typeof payload === 'object' && payload !== null ? (payload as { slug?: unknown }).slug : undefined;
if (typeof slug !== 'string') {
throw new Error('Slug de juego inválido.');
}
const removed = await removeGameAssets(slug);
ack(null, {
removed,
installedGames: await listInstalledGames(),
});
} catch (error) {
ack((error as Error).message);
}
});
+1
View File
@@ -11,4 +11,5 @@ export default async (nodecg: NodeCGServerAPI) => {
await import('./example.js'); await import('./example.js');
await import('./startgg.js'); await import('./startgg.js');
await import('./challonge.js'); await import('./challonge.js');
await import('./game-assets.js');
}; };
+323 -230
View File
@@ -1,8 +1,7 @@
import { createServer, type Server, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { getData, type CountryRecord } from 'country-list'; import { getData, type CountryRecord } from 'country-list';
import { nodecg } from './util/nodecg.js'; import { nodecg } from './util/nodecg.js';
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
// ─── Constantes ────────────────────────────────────────────────────────────────
const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha'; 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_AUTHORIZE_ENDPOINT = 'https://www.start.gg/api/-/rest/oauth/authorize';
@@ -18,16 +17,6 @@ const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 12; const RECENT_TOURNAMENTS_LIMIT = 12;
const PARTICIPANTS_PAGE_SIZE = 120; const PARTICIPANTS_PAGE_SIZE = 120;
// ─── URL del proxy OAuth ───────────────────────────────────────────────────────
// Rellena esta constante con la URL de tu Cloudflare Worker tras el deploy.
// Formato: 'https://scoreko-oauth-proxy.TU-SUBDOMINIO.workers.dev'
//
// También puedes sobreescribirla en cfg/scoreko.json con "oauthProxyUrl"
// (útil para apuntar a un entorno de staging sin recompilar).
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
// ─── Tipos ─────────────────────────────────────────────────────────────────────
interface StartGGGraphQLResponse<T> { interface StartGGGraphQLResponse<T> {
data?: T; data?: T;
errors?: Array<{ message?: string }>; errors?: Array<{ message?: string }>;
@@ -50,6 +39,21 @@ interface ImportedPlayer {
twitter: string; 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 { interface OAuthTokenResponse {
access_token?: string; access_token?: string;
error?: string; error?: string;
@@ -57,162 +61,31 @@ interface OAuthTokenResponse {
message?: string; message?: string;
} }
// ─── Modo OAuth ──────────────────────────────────────────────────────────────── const oauthSessions = new Map<string, OAuthSession>();
// let oauthCallbackServer: Server | null = null;
// DEV: cfg/scoreko.json tiene startggClientId + startggClientSecret.
// El exchange se hace directamente contra start.gg.
//
// PROXY: No hay credenciales en la config local.
// El clientId se obtiene del Worker (es público, no secreto).
// El exchange lo hace el Worker, que guarda el clientSecret en sus env vars.
type OAuthMode = const getStringProp = (payload: unknown, key: string): string => {
| { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number } if (typeof payload !== 'object' || payload === null || !(key in payload)) {
| { type: 'proxy'; proxyBaseUrl: string; callbackPort: number }; return '';
const getOAuthMode = (): OAuthMode => {
const bundleConfig = nodecg.bundleConfig 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;
// oauthProxyUrl en config permite apuntar a un proxy distinto sin recompilar
const proxyBaseUrl =
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
if (clientId && clientSecret) {
nodecg.log.info('[start.gg] OAuth: modo dev (credenciales locales)');
return { type: 'dev', clientId, clientSecret, callbackPort };
} }
nodecg.log.info(`[start.gg] OAuth: modo proxy → ${proxyBaseUrl}`); const value = (payload as Record<string, unknown>)[key];
return { type: 'proxy', proxyBaseUrl, callbackPort }; return typeof value === 'string' ? value.trim() : String(value || '').trim();
}; };
// ─── Exchange de token ───────────────────────────────────────────────────────── const updateOAuthSession = (sessionId: string, update: Partial<OAuthSession>) => {
const session = oauthSessions.get(sessionId);
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => { if (!session) {
const rawBody = await response.text(); return;
try {
return JSON.parse(rawBody) as OAuthTokenResponse;
} catch {
return { message: rawBody };
} }
};
/** Modo dev: exchange directo con start.gg usando credenciales locales */ oauthSessions.set(sessionId, {
const exchangeCodeDirectly = async ( ...session,
code: string, ...update,
redirectUri: string,
clientId: string,
clientSecret: string,
): Promise<string> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: clientId,
client_secret: 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);
}; };
/** Modo proxy: el Worker hace el exchange; el clientSecret nunca sale del Worker */ const requestStartGG = async <T>(query: string, variables: Record<string, unknown>, token: string): Promise<T> => {
const exchangeCodeViaProxy = async (
code: string,
redirectUri: string,
proxyBaseUrl: string,
): Promise<string> => {
const response = await fetch(`${proxyBaseUrl}/oauth/startgg/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, redirectUri }),
});
const rawBody = await response.text();
let payload: { access_token?: string; error?: string };
try {
payload = JSON.parse(rawBody) as typeof payload;
} catch {
payload = { error: rawBody };
}
if (!response.ok) {
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
}
const token = String(payload.access_token ?? '').trim();
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
return token;
};
/**
* Callback que recibe oauth-server.ts cuando llega el código de autorización.
* Delega al modo correcto; _config no se usa porque el modo ya está determinado.
*/
const exchangeOAuthCodeForToken = async (
code: string,
redirectUri: string,
_config: OAuthConfig,
): Promise<string> => {
const mode = getOAuthMode();
if (mode.type === 'dev') {
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
}
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
};
// ─── Servidor OAuth ────────────────────────────────────────────────────────────
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,
});
// ─── GraphQL ───────────────────────────────────────────────────────────────────
const requestStartGG = async <T>(
query: string,
variables: Record<string, unknown>,
token: string,
): Promise<T> => {
const response = await fetch(STARTGG_ENDPOINT, { const response = await fetch(STARTGG_ENDPOINT, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -234,7 +107,7 @@ const requestStartGG = async <T>(
} }
if (payload.errors?.length) { 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) { if (!payload.data) {
@@ -244,92 +117,286 @@ const requestStartGG = async <T>(
return payload.data; return payload.data;
}; };
// ─── Resolución de países ──────────────────────────────────────────────────────
const countries = getData(); const countries = getData();
const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase())); const countryByCode = new Set(countries.map((country: CountryRecord) => country.code.toUpperCase()));
const countryByName = new Map( const countryByName = new Map(countries.map((country: CountryRecord) => [country.name.toLowerCase(), country.code.toUpperCase()]));
countries.map((c: CountryRecord) => [c.name.toLowerCase(), c.code.toUpperCase()]),
);
const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => { const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
const raw = (country ?? '').trim(); const raw = (country || '').trim();
if (!raw) return ''; if (!raw) {
return '';
}
const upper = raw.toUpperCase(); const upper = raw.toUpperCase();
if (countryByCode.has(upper)) return upper; if (countryByCode.has(upper)) {
return upper;
}
return countryByName.get(raw.toLowerCase()) ?? ''; 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) => { 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;
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => { if (!clientId || !clientSecret) {
const mode = getOAuthMode(); return null;
let serverConfig: OAuthConfig; }
if (mode.type === 'dev') { return {
serverConfig = { clientId,
clientId: mode.clientId, clientSecret,
callbackPort: mode.callbackPort, callbackPort,
}; };
} else { };
// Modo proxy: el clientId viene del Worker.
// Es público (va en la URL del navegador), pero no lo queremos en el repo. const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPort}${STARTGG_OAUTH_CALLBACK_PATH}`;
try {
const res = await fetch(`${mode.proxyBaseUrl}/oauth/startgg/client-id`); const cleanupExpiredOAuthSessions = () => {
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`); const now = Date.now();
const data = await res.json() as { clientId?: string }; oauthSessions.forEach((session, sessionId) => {
const clientId = String(data.clientId ?? '').trim(); if (session.expiresAt <= now && session.status === 'pending') {
if (!clientId) throw new Error('Proxy did not return a clientId'); updateOAuthSession(sessionId, { status: 'expired' });
serverConfig = { clientId, callbackPort: mode.callbackPort }; }
} catch (err) { });
sendAck( };
ack,
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy', const respondWithCallbackHtml = (res: ServerResponse, statusCode: number, title: string, message: string) => {
); res.statusCode = statusCode;
return; 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;
} }
} }
try { throw new Error(lastError);
await oauthServer.ensureServer(serverConfig); };
} catch (err) {
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server'); const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => {
if (oauthCallbackServer) {
return; return;
} }
sendAck(ack, null, oauthServer.createSession(serverConfig)); 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 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 ensureOAuthCallbackServer(oauthConfig);
} catch (serverError) {
const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback';
sendAck(ack, message);
return;
}
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) => { nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
cleanupExpiredOAuthSessions();
const sessionId = getStringProp(payload, 'sessionId'); const sessionId = getStringProp(payload, 'sessionId');
if (!sessionId) { if (!sessionId) {
sendAck(ack, 'Missing OAuth session id'); sendAck(ack, 'Missing OAuth session id');
return; return;
} }
const status = oauthServer.getSessionStatus(sessionId); const session = oauthSessions.get(sessionId);
if (!status) { if (!session) {
sendAck(ack, 'OAuth session not found'); sendAck(ack, 'OAuth session not found');
return; 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) => { nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
const token = getStringProp(payload, 'token'); const token = getStringProp(payload, 'token');
if (!token) { if (!token) {
sendAck(ack, 'Missing start.gg API token'); sendAck(ack, 'Missing start.gg API token');
return; return;
@@ -356,15 +423,21 @@ nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack)
currentUser: { tournaments: { nodes: RecentTournament[] } } | null; currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token); }>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
const tournaments = const tournaments = data.currentUser?.tournaments.nodes
data.currentUser?.tournaments.nodes .filter((item) => item.slug)
.filter((item) => item.slug) .sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0)) .map((item) => ({
.map(({ id, name, slug, startAt, endAt }) => ({ id, name, slug, startAt, endAt })) ?? []; id: item.id,
name: item.name,
slug: item.slug,
startAt: item.startAt,
endAt: item.endAt,
})) ?? [];
sendAck(ack, null, tournaments); sendAck(ack, null, tournaments);
} catch (error) { } 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);
} }
}); });
@@ -372,8 +445,15 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack)
const token = getStringProp(payload, 'token'); const token = getStringProp(payload, 'token');
const slug = getStringProp(payload, 'slug'); const slug = getStringProp(payload, 'slug');
if (!token) { sendAck(ack, 'Missing start.gg API token'); return; } if (!token) {
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; } sendAck(ack, 'Missing start.gg API token');
return;
}
if (!slug) {
sendAck(ack, 'Missing tournament slug');
return;
}
const query = ` const query = `
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) { query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
@@ -411,37 +491,50 @@ nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack)
id: number; id: number;
gamerTag: string | null; gamerTag: string | null;
prefix: string | null; prefix: string | null;
user: { location: { country: string | null } | null } | null; user: {
location: {
country: string | null;
} | 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); const apiTotalPages = Number(data.tournament.participants.pageInfo.totalPages);
totalPages = Number.isFinite(apiTotalPages) ? Math.max(apiTotalPages, 1) : 1; 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 playerId = String(participant.id);
const gamertag = (participant.gamerTag ?? '').trim(); const gamertag = (participant.gamerTag || '').trim();
if (!gamertag) continue; if (!gamertag) {
return;
}
const country = resolveCountryCodeFromStartGG(participant.user?.location?.country);
playersMap.set(playerId, { playersMap.set(playerId, {
id: playerId, id: playerId,
gamertag, gamertag,
name: gamertag, name: gamertag,
team: (participant.prefix ?? '').trim(), team: (participant.prefix || '').trim(),
country: resolveCountryCodeFromStartGG(participant.user?.location?.country), country,
twitter: '', twitter: '',
}); });
} });
currentPage += 1; currentPage += 1;
} }
sendAck(ack, null, Array.from(playersMap.values())); sendAck(ack, null, Array.from(playersMap.values()));
} catch (error) { } 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);
} }
}); });
-275
View File
@@ -1,275 +0,0 @@
import { createServer, type Server, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
// ─── Tipos públicos ────────────────────────────────────────────────────────────
export interface OAuthConfig {
clientId: string;
/** Solo necesario en modo dev (exchange directo con el proveedor).
* En modo proxy el exchange lo hace el Worker y no necesita el secret. */
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 };
};
+7 -4
View File
@@ -3,7 +3,7 @@ import { useHead } from '@unhead/vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants'; import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
import { resolveCountryCode } from '../../shared/countries'; import { resolveCountryCode } from '../../shared/countries';
import { getCharactersByGame } from '../../shared/fighting-characters'; import { getCharacterAssetUrl } from '../../shared/fighting-characters';
import type { Schemas } from '../../types'; import type { Schemas } from '../../types';
useHead({ title: 'Scoreboard 2XKO' }); useHead({ title: 'Scoreboard 2XKO' });
@@ -35,9 +35,12 @@ const rightName = computed(() => scoreboard.value.rightNameOverride || players.v
const leftTeam = computed(() => scoreboard.value.leftTeamOverride); const leftTeam = computed(() => scoreboard.value.leftTeamOverride);
const rightTeam = computed(() => scoreboard.value.rightTeamOverride); const rightTeam = computed(() => scoreboard.value.rightTeamOverride);
const charMap = new Map(getCharactersByGame('2XKO').map((char) => [char.value, char.image])); const leftCharacterImage = computed(() => scoreboard.value.leftCharacter
const leftCharacterImage = computed(() => charMap.get(scoreboard.value.leftCharacter) ?? ''); ? getCharacterAssetUrl('2XKO', scoreboard.value.leftCharacter)
const rightCharacterImage = computed(() => charMap.get(scoreboard.value.rightCharacter) ?? ''); : '');
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 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> = {}; const flagUrlCache: Record<string, string> = {};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 780 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

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

Before

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 547 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 599 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 776 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

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