Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b32c0e4560 | |||
| 02a108f983 | |||
| 225b2b36a2 | |||
| 8c270feb5b | |||
| 618d18d8fb | |||
| 0bc6f60b2c | |||
| 88aeedb5ff | |||
| 04f2c2037a | |||
| fd4201a882 | |||
| 787de05034 | |||
| 67d9d20b56 | |||
| 79f6653d94 | |||
| 27c0298ca2 | |||
| aea381ea35 | |||
| 0857472ad4 | |||
| 661cf1264a | |||
| b3fc84fde2 | |||
| 3de99ef810 | |||
| 37f9ffb786 | |||
| d76a51c321 | |||
| 2ee111a0ca | |||
| 7b302e4c21 | |||
| 6dbf648323 | |||
| a4dc89575d | |||
| 4da00508d3 | |||
| 21d885f6e6 | |||
| d8d3c7f03c | |||
| 7314e73a1b | |||
| 61e565d358 | |||
| 8040b4fe51 | |||
| 91a8ce730c | |||
| 7a5c1ec637 |
@@ -23,17 +23,22 @@ 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: 22
|
node-version: 24
|
||||||
cache: npm
|
cache: pnpm
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: pnpm run lint
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: pnpm run build
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
pnpm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
@@ -48,8 +48,11 @@ web_modules/
|
|||||||
# TypeScript cache
|
# TypeScript cache
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
# Optional npm cache directory
|
# Optional pnpm cache directory
|
||||||
.npm
|
.pnpm-store
|
||||||
|
.corepack/
|
||||||
|
.npm-cache/
|
||||||
|
.node-gyp/
|
||||||
|
|
||||||
# Optional eslint cache
|
# Optional eslint cache
|
||||||
.eslintcache
|
.eslintcache
|
||||||
@@ -66,7 +69,7 @@ web_modules/
|
|||||||
# Optional REPL history
|
# Optional REPL history
|
||||||
.node_repl_history
|
.node_repl_history
|
||||||
|
|
||||||
# Output of 'npm pack'
|
# Output of 'pnpm pack'
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|
||||||
# Yarn Integrity file
|
# Yarn Integrity file
|
||||||
@@ -133,4 +136,12 @@ dist
|
|||||||
/dashboard/
|
/dashboard/
|
||||||
/extension/
|
/extension/
|
||||||
/graphics/
|
/graphics/
|
||||||
|
/nodecg/
|
||||||
|
/shared/domain/
|
||||||
/shared/dist/
|
/shared/dist/
|
||||||
|
|
||||||
|
# Local runtime database
|
||||||
|
/db/
|
||||||
|
*.sqlite3
|
||||||
|
/scoreko-electron-dev/
|
||||||
|
/packs/
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ NodeCG bundle for producing fighting game overlays.
|
|||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
- `npm run autofix`: automatically fixes lint errors.
|
- `pnpm run autofix`: automatically fixes lint errors.
|
||||||
- `npm run build`: builds dashboard/graphics and extension.
|
- `pnpm run build`: builds dashboard/graphics and extension.
|
||||||
- `npm run lint`: validates project linting.
|
- `pnpm run lint`: validates project linting.
|
||||||
- `npm run schema-types`: generates types from schemas.
|
- `pnpm run schema-types`: generates types from schemas.
|
||||||
- `npm run start`: starts NodeCG.
|
- `pnpm run start`: starts NodeCG.
|
||||||
- `npm run watch`: development mode with watch.
|
- `pnpm run watch`: development mode with watch.
|
||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
|
|||||||
@@ -3,45 +3,39 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"exampleProperty": {
|
"oauthProxyUrl": {
|
||||||
"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."
|
||||||
},
|
},
|
||||||
"startggClientId": {
|
"startggClientId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"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)."
|
||||||
"description": "Client ID de tu OAuth app de start.gg"
|
|
||||||
},
|
},
|
||||||
"startggClientSecret": {
|
"startggClientSecret": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"description": "DEV ONLY: Client Secret de tu propia OAuth app de start.gg. NUNCA subas este valor a git."
|
||||||
"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 callback OAuth"
|
"description": "Puerto local para el servidor de callback OAuth de start.gg."
|
||||||
},
|
},
|
||||||
"challongeClientId": {
|
"challongeClientId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"description": "DEV ONLY: Client ID de tu propia OAuth app de Challonge. Si está presente junto a challongeClientSecret, activa el modo dev."
|
||||||
"description": "Client ID de tu OAuth app de Challonge"
|
|
||||||
},
|
},
|
||||||
"challongeClientSecret": {
|
"challongeClientSecret": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"description": "DEV ONLY: Client Secret de tu propia OAuth app de Challonge. NUNCA subas este valor a git."
|
||||||
"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 callback OAuth de Challonge"
|
"description": "Puerto local para el servidor de callback OAuth de Challonge."
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"required": [
|
|
||||||
"exampleProperty"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Architecture Audit
|
||||||
|
|
||||||
|
Este documento resume el estado arquitectónico actual de Scoreko y debe usarse como referencia para el refactor. No redefine la arquitectura objetivo; documenta el diagnóstico existente y los riesgos detectados.
|
||||||
|
|
||||||
|
## Estado Actual
|
||||||
|
|
||||||
|
La repo es un bundle NodeCG con Vite, Vue 3, Quasar, Pinia y `vite-plugin-nodecg`.
|
||||||
|
|
||||||
|
| Área | Ruta | Responsabilidad actual |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Extension | `src/extension` | Lógica server NodeCG, OAuth, brackets y pack manager. |
|
||||||
|
| Dashboard | `src/dashboard/scoreko-dev` | App Quasar/Pinia para control. |
|
||||||
|
| Graphics | `src/graphics` | Overlays broadcast: `scoreboard`, `scoreboard-2xko`, `commentary`. |
|
||||||
|
| Browser shared | `src/browser_shared/replicants.ts` | Acceso browser a replicants. |
|
||||||
|
| Shared | `src/shared` | Tipos, utilidades, países, packs y personajes. |
|
||||||
|
| Schemas | `schemas` | Schemas de replicants principales. |
|
||||||
|
| Build outputs | `dashboard`, `graphics`, `extension`, `shared/dist` | Outputs ignorados por git. |
|
||||||
|
|
||||||
|
## Replicants
|
||||||
|
|
||||||
|
### Declarados por Schema
|
||||||
|
|
||||||
|
- `scoreboard`
|
||||||
|
- `players`
|
||||||
|
- `commentary`
|
||||||
|
- `graphicsSettings`
|
||||||
|
- `exampleReplicant`
|
||||||
|
|
||||||
|
### Declarados Solo en Código
|
||||||
|
|
||||||
|
- `installedPacks`
|
||||||
|
- `packRegistry`
|
||||||
|
- `downloadStates`
|
||||||
|
- `availableUpdates`
|
||||||
|
|
||||||
|
### Problema Principal
|
||||||
|
|
||||||
|
El contrato realtime está dividido entre schemas y código runtime. Parte vive en `schemas`, y parte en `pack-manager` o `usePackRegistry`. Esto dificulta validar cambios, regenerar tipos y entender qué estado público existe realmente.
|
||||||
|
|
||||||
|
## Flujo de Datos
|
||||||
|
|
||||||
|
1. El dashboard escribe en Pinia stores.
|
||||||
|
2. Los stores sincronizan con replicants mediante `store-sync.ts`.
|
||||||
|
3. Los overlays leen replicants directamente desde `browser_shared/replicants.ts`.
|
||||||
|
4. La extensión escucha mensajes NodeCG como `startgg:*`, `challonge:*` y `downloadPack`.
|
||||||
|
5. La extensión actualiza replicants o disco.
|
||||||
|
6. `graphicsSettings.scoreboardSkin` redirige entre overlays `scoreboard` y `scoreboard-2xko`.
|
||||||
|
|
||||||
|
## Zonas Grandes o de Riesgo
|
||||||
|
|
||||||
|
| Archivo | Riesgo |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/dashboard/scoreko-dev/views/Players.vue` | Vista demasiado grande: tabla, CRUD, import/export, integraciones y dialogs. |
|
||||||
|
| `src/dashboard/scoreko-dev/components/PlayerSidePanel.vue` | UI duplicada para left/right. |
|
||||||
|
| `src/dashboard/scoreko-dev/views/Settings.vue` | Mezcla idioma, shortcuts, OAuth y tokens. |
|
||||||
|
| `src/dashboard/scoreko-dev/composables/useIntegration.ts` | Mezcla estado UI, localStorage, polling OAuth, importación y cleanup. |
|
||||||
|
| `src/extension/pack-manager.ts` | Mezcla config, tipos, FS, HTTP static, downloads, updates, replicants y handlers. |
|
||||||
|
| `src/graphics/scoreboard/main.vue` | Lógica, layout, animaciones, flags y CSS mezclados. |
|
||||||
|
| `src/graphics/scoreboard-2xko/main.vue` | Mismo riesgo que `scoreboard/main.vue`. |
|
||||||
|
|
||||||
|
## Hallazgos Técnicos
|
||||||
|
|
||||||
|
- No se detectaron ciclos de imports en los archivos TS/Vue revisados.
|
||||||
|
- Sí hay accesos frágiles al entorno NodeCG:
|
||||||
|
- `nodecg` global en composables.
|
||||||
|
- `NodeCG.Replicant` declarado manualmente.
|
||||||
|
- Imports a `package.json` desde UI.
|
||||||
|
- Tipos generados desalineados.
|
||||||
|
- Acceso directo a replicants fuera de stores o servicios.
|
||||||
|
|
||||||
|
## Problemas Reales
|
||||||
|
|
||||||
|
- La frontera NodeCG está rota: `browser_shared/replicants.ts`, stores, `Graphics.vue`, overlays y `usePackRegistry` acceden a NodeCG de formas distintas.
|
||||||
|
- Los contratos realtime no están centralizados.
|
||||||
|
- `pack-manager.ts` necesita reescritura controlada, no parcheo incremental.
|
||||||
|
- `usePackRegistry.ts` y `fighting-characters.ts` necesitan reescritura controlada.
|
||||||
|
- `startgg.ts` y `challonge.ts` duplican estructura: OAuth mode, proxy exchange, session polling API y parsing básico.
|
||||||
|
- `Players.vue` y `Settings.vue` son feature modules completos metidos en vistas.
|
||||||
|
- Los overlays son de alto riesgo visual y deben tocarse con mucho cuidado.
|
||||||
|
- Hay dead code claro: `exampleReplicant`, `example.ts`, `ExampleType` y schema de ejemplo.
|
||||||
|
- `src/types/schemas/configschema.d.ts` está stale: contiene `exampleProperty`, pero `configschema.json` ya no lo define.
|
||||||
|
- `lint` falla por `_id` en `Players.vue` y `_config` en `startgg.ts` y `challonge.ts`.
|
||||||
|
- El resto de lint son warnings de formato Vue.
|
||||||
|
|
||||||
|
## Impacto a Medio y Largo Plazo
|
||||||
|
|
||||||
|
- Añadir providers o skins duplicará lógica.
|
||||||
|
- Contributors externos no sabrán dónde tocar: store, composable, replicant o extensión.
|
||||||
|
- Refactors visuales pueden romper overlays porque no hay separación entre view model y presentación.
|
||||||
|
- El sistema de packs es el mayor riesgo por acoplar descarga, estado realtime, manifests, FS y UI.
|
||||||
|
|
||||||
|
## Zonas a Preservar
|
||||||
|
|
||||||
|
- La idea de `syncStateWithReplicant`.
|
||||||
|
- Stores `scoreboard`, `players` y `commentary` como base razonable.
|
||||||
|
- `oauth-server.ts` como pieza reusable.
|
||||||
|
- `countries.ts`, que está bien encapsulado.
|
||||||
|
- Schemas JSON como fuente de tipos.
|
||||||
|
- UI Quasar existente, preservando comportamiento visual mientras se divide.
|
||||||
|
|
||||||
|
## Baseline de Checks
|
||||||
|
|
||||||
|
| Check | Estado |
|
||||||
|
| --- | --- |
|
||||||
|
| `vue-tsc` | Pasa. |
|
||||||
|
| `tsc` | Pasa. |
|
||||||
|
| `lint` | Falla con 3 errores reales y 243 warnings de formato. |
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Architecture Rules
|
||||||
|
|
||||||
|
Estas reglas son obligatorias para cualquier refactor posterior. Están pensadas para mantener boundaries claros, reducir acoplamiento y preservar comportamiento durante la migración.
|
||||||
|
|
||||||
|
## TypeScript
|
||||||
|
|
||||||
|
- No usar `any`.
|
||||||
|
- Usar `unknown` solo en boundaries.
|
||||||
|
- Normalizar `unknown` inmediatamente al entrar al dominio.
|
||||||
|
- No duplicar tipos entre extension y browser.
|
||||||
|
- Todo replicant nuevo debe tener schema y tipo generado.
|
||||||
|
- Regenerar tipos siempre desde schemas.
|
||||||
|
|
||||||
|
## Boundaries NodeCG
|
||||||
|
|
||||||
|
- No acceder directamente a replicants fuera de `nodecg/browser` o `nodecg/extension`.
|
||||||
|
- No usar `nodecg.sendMessage` directo en componentes o composables de feature.
|
||||||
|
- No usar `nodecg.Replicant` directo fuera de la capa `nodecg`.
|
||||||
|
- No depender de `nodecg` global salvo dentro del boundary correspondiente.
|
||||||
|
- Centralizar nombres de replicants en `replicantNames`.
|
||||||
|
- Centralizar nombres de messages en una capa de messages.
|
||||||
|
|
||||||
|
## Shared y Dominio
|
||||||
|
|
||||||
|
- `shared/domain` solo puede contener funciones puras, tipos, normalizadores y mapping.
|
||||||
|
- `shared/domain` no puede importar Vue.
|
||||||
|
- `shared/domain` no puede importar NodeCG.
|
||||||
|
- `shared/domain` no puede acceder al DOM.
|
||||||
|
- Preferir funciones puras para normalización, parsing, derivación y mapping.
|
||||||
|
- Validar o normalizar datos externos al cruzar boundaries.
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
- Los stores mantienen estado de aplicación y sync con replicants.
|
||||||
|
- Los stores no deben contener UI compleja.
|
||||||
|
- Las vistas no deben implementar features completos.
|
||||||
|
- Los componentes deben ser pequeños y orientados a UI.
|
||||||
|
- Los composables de feature no deben hablar directamente con NodeCG.
|
||||||
|
- Toda lógica de negocio debe vivir en dominio, services o stores según corresponda.
|
||||||
|
|
||||||
|
## Extension
|
||||||
|
|
||||||
|
- `extension/modules` debe contener handlers pequeños registrados desde bootstrap explícito.
|
||||||
|
- No mezclar FS, HTTP, replicants, downloads y parsing en el mismo módulo.
|
||||||
|
- Todo handler NodeCG debe declarar claramente qué message escucha y qué replicants toca.
|
||||||
|
- Todo acceso a `nodecg.listenFor`, `nodecg.Replicant`, `nodecg.mount` y logging debe pasar por `nodecg/extension`.
|
||||||
|
|
||||||
|
## Packs
|
||||||
|
|
||||||
|
- Pack registry, manifests, downloads y estado instalado deben compartir tipos comunes.
|
||||||
|
- Todo replicant de packs debe tener schema.
|
||||||
|
- Mantener nombres y defaults actuales durante la migración.
|
||||||
|
- Validar manifests en el boundary antes de exponerlos al dominio o UI.
|
||||||
|
- No mantener estado mutable de módulo opaco para packs instalados.
|
||||||
|
- No usar `ref` de Vue dentro de shared.
|
||||||
|
|
||||||
|
## Integraciones
|
||||||
|
|
||||||
|
- Providers como Start.gg y Challonge deben compartir patrón de OAuth, session polling y parsing.
|
||||||
|
- Cada provider debe tener cliente propio y normalizadores propios.
|
||||||
|
- El flujo OAuth debe apoyarse en `oauth-server.ts` cuando aplique.
|
||||||
|
- Todo timer o polling debe tener cleanup.
|
||||||
|
- Todo listener debe tener cleanup.
|
||||||
|
|
||||||
|
## Graphics y Overlays
|
||||||
|
|
||||||
|
- Los overlays se refactorizan al final.
|
||||||
|
- Primero preservar píxel y comportamiento; después limpiar internals.
|
||||||
|
- No cambiar CSS, SVG, posiciones o markup sensible sin baseline visual.
|
||||||
|
- Extraer view models antes de deduplicar layout.
|
||||||
|
- Helpers compartidos de flags, score animation y text fitting deben vivir en `graphics/shared`.
|
||||||
|
|
||||||
|
## Side Effects
|
||||||
|
|
||||||
|
- No side effects en imports salvo bootstrap explícito.
|
||||||
|
- No estado mutable de módulo salvo singleton justificado y documentado.
|
||||||
|
- Todo timer/listener debe registrar cleanup.
|
||||||
|
- No wrappers vacíos.
|
||||||
|
- No inventar un patrón si una función simple basta.
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
| Elemento | Regla | Ejemplo |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Replicants | `camelCase`, constantes en `replicantNames` | `graphicsSettings` |
|
||||||
|
| Messages | Namespaced por dominio | `packs:download` |
|
||||||
|
| Stores | `use<Feature>Store` | `useScoreboardStore` |
|
||||||
|
| Services | `create<Domain>Service` o `create<Provider>Client` | `createPackService` |
|
||||||
|
| View models | `use<Overlay>ViewModel` | `useScoreboardOverlayViewModel` |
|
||||||
|
|
||||||
|
## Compatibilidad
|
||||||
|
|
||||||
|
- Mantener comportamiento público durante la migración.
|
||||||
|
- Mantener nombres públicos hasta completar el refactor.
|
||||||
|
- No romper overlays sin baseline visual y verificación.
|
||||||
|
- Priorizar eliminar legacy muerto antes que envolverlo.
|
||||||
|
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# Migration Plan
|
||||||
|
|
||||||
|
Este plan define el orden de migración. Debe ejecutarse de forma secuencial para reducir riesgo, preservar comportamiento y evitar reescrituras amplias sin baseline.
|
||||||
|
|
||||||
|
## Principios
|
||||||
|
|
||||||
|
- Mantener nombres públicos y comportamiento hasta completar la migración.
|
||||||
|
- Congelar comportamiento antes de mover responsabilidades.
|
||||||
|
- Eliminar legacy muerto antes de envolverlo.
|
||||||
|
- Separar boundaries antes de reescribir módulos complejos.
|
||||||
|
- Tocar overlays al final y con verificación visual.
|
||||||
|
|
||||||
|
## Secuencia
|
||||||
|
|
||||||
|
| Paso | Objetivo | Tipo |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | Congelar comportamiento con screenshots de overlays, fixtures de replicants, build, typecheck y lint baseline. | Baseline |
|
||||||
|
| 2 | Quitar `example*`, regenerar schema types y eliminar `.js` redundantes en `src/shared` si no se usan. | Limpieza |
|
||||||
|
| 3 | Crear `nodecg/browser` y `nodecg/extension` sin cambiar comportamiento. | Boundary |
|
||||||
|
| 4 | Añadir schemas para replicants de packs manteniendo nombres y defaults exactos. | Contratos |
|
||||||
|
| 5 | Extraer tipos/config de packs a `shared` y ajustar `tsconfig.extension` para no duplicar. | Shared |
|
||||||
|
| 6 | Reescribir controladamente `pack-manager`. | Reescritura |
|
||||||
|
| 7 | Reescribir controladamente `usePackRegistry` y `fighting-characters`. | Reescritura |
|
||||||
|
| 8 | Dividir `useIntegration`. | Modularización |
|
||||||
|
| 9 | Dividir `Players.vue`. | Modularización |
|
||||||
|
| 10 | Dividir `Settings.vue`. | Modularización |
|
||||||
|
| 11 | Refactor suave de dashboard scoreboard para eliminar duplicación left/right. | Modularización |
|
||||||
|
| 12 | Extraer view models y helpers de overlays sin tocar CSS/markup al inicio. | Overlays |
|
||||||
|
| 13 | Añadir tests puros para normalizadores y lógica de dominio. | Tests |
|
||||||
|
| 14 | Añadir verificación visual Playwright para overlays principales. | Visual QA |
|
||||||
|
|
||||||
|
## Detalle por Fase
|
||||||
|
|
||||||
|
### 1. Congelar Comportamiento
|
||||||
|
|
||||||
|
Crear una baseline antes de refactorizar:
|
||||||
|
|
||||||
|
- Screenshots de `scoreboard`, `scoreboard-2xko` y `commentary`.
|
||||||
|
- Fixtures representativos de replicants.
|
||||||
|
- Resultado actual de build y typecheck.
|
||||||
|
- Resultado actual de lint, incluyendo los 3 errores reales conocidos.
|
||||||
|
|
||||||
|
### 2. Limpiar Dead y Stale Code
|
||||||
|
|
||||||
|
Eliminar código que no representa comportamiento productivo:
|
||||||
|
|
||||||
|
- `exampleReplicant`.
|
||||||
|
- `example.ts`.
|
||||||
|
- `ExampleType`.
|
||||||
|
- Schema de ejemplo.
|
||||||
|
- Tipos generados stale.
|
||||||
|
- `.js` redundantes en `src/shared` si se confirma que no se usan.
|
||||||
|
|
||||||
|
### 3. Crear Boundaries NodeCG
|
||||||
|
|
||||||
|
Introducir APIs sin cambiar comportamiento:
|
||||||
|
|
||||||
|
- `src/nodecg/browser/replicants.ts`
|
||||||
|
- `src/nodecg/browser/messages.ts`
|
||||||
|
- `src/nodecg/extension/replicants.ts`
|
||||||
|
- `src/nodecg/extension/messages.ts`
|
||||||
|
- `src/nodecg/extension/context.ts`
|
||||||
|
|
||||||
|
El objetivo es centralizar acceso a replicants, messages, logging y NodeCG globals.
|
||||||
|
|
||||||
|
### 4. Centralizar Contratos Realtime
|
||||||
|
|
||||||
|
Añadir schemas para:
|
||||||
|
|
||||||
|
- `installedPacks`
|
||||||
|
- `packRegistry`
|
||||||
|
- `downloadStates`
|
||||||
|
- `availableUpdates`
|
||||||
|
|
||||||
|
Los nombres y defaults deben mantenerse exactamente para no romper dashboard, extensión ni overlays.
|
||||||
|
|
||||||
|
### 5. Extraer Shared de Packs
|
||||||
|
|
||||||
|
Mover a `shared`:
|
||||||
|
|
||||||
|
- Tipos de manifest.
|
||||||
|
- Tipos de registry.
|
||||||
|
- Tipos de estado de descarga.
|
||||||
|
- Config derivada común.
|
||||||
|
- Validación ligera en boundaries.
|
||||||
|
|
||||||
|
No duplicar tipos entre extension y browser.
|
||||||
|
|
||||||
|
### 6. Reescritura Controlada de Pack Manager
|
||||||
|
|
||||||
|
Separar `pack-manager.ts` en módulos pequeños:
|
||||||
|
|
||||||
|
| Módulo | Responsabilidad |
|
||||||
|
| --- | --- |
|
||||||
|
| Registry client | Fetch y normalización del registry remoto. |
|
||||||
|
| Downloader | Descargas, progreso y errores. |
|
||||||
|
| Disk store | Lectura/escritura en disco. |
|
||||||
|
| Static mount | Exposición HTTP de assets instalados. |
|
||||||
|
| Handlers | Registro de mensajes NodeCG. |
|
||||||
|
| Replicant sync | Actualización centralizada de replicants. |
|
||||||
|
|
||||||
|
### 7. Reescritura de Pack Registry Runtime
|
||||||
|
|
||||||
|
Rehacer `usePackRegistry` y `fighting-characters` para:
|
||||||
|
|
||||||
|
- Quitar estado mutable de módulo opaco.
|
||||||
|
- Evitar `ref` en shared.
|
||||||
|
- Modelar packs instalados como estado explícito.
|
||||||
|
- Cargar manifests mediante boundaries claros.
|
||||||
|
- Eliminar comentarios que contradicen el estado real de assets.
|
||||||
|
|
||||||
|
### 8. Dividir Integraciones
|
||||||
|
|
||||||
|
Extraer `useIntegration` en piezas:
|
||||||
|
|
||||||
|
- `nodecgMessageClient`
|
||||||
|
- `oauthClient`
|
||||||
|
- `temporaryPlayers`
|
||||||
|
- `tournamentImport`
|
||||||
|
|
||||||
|
Cada pieza debe tener cleanup explícito para timers/listeners.
|
||||||
|
|
||||||
|
### 9. Dividir Players
|
||||||
|
|
||||||
|
Extraer desde `Players.vue`:
|
||||||
|
|
||||||
|
- `PlayersTable`
|
||||||
|
- `PlayerEditorDialog`
|
||||||
|
- `IntegrationImportCard`
|
||||||
|
- `ImportPlayersDialog`
|
||||||
|
|
||||||
|
La vista debe coordinar el feature, no contener la implementación completa.
|
||||||
|
|
||||||
|
### 10. Dividir Settings
|
||||||
|
|
||||||
|
Extraer desde `Settings.vue`:
|
||||||
|
|
||||||
|
- `LanguageSettings`
|
||||||
|
- `ShortcutSettings`
|
||||||
|
- `IntegrationSettings`
|
||||||
|
|
||||||
|
Mantener UI Quasar y comportamiento actual.
|
||||||
|
|
||||||
|
### 11. Refactor Suave de Scoreboard Dashboard
|
||||||
|
|
||||||
|
Eliminar duplicación left/right con subcomponentes pequeños, sin cambiar el comportamiento público ni el layout principal.
|
||||||
|
|
||||||
|
### 12. Overlays al Final
|
||||||
|
|
||||||
|
Primero extraer sin modificar presentación:
|
||||||
|
|
||||||
|
- View models.
|
||||||
|
- Helpers de flags.
|
||||||
|
- Helpers de score animation.
|
||||||
|
- Helpers de text fitting.
|
||||||
|
|
||||||
|
Después de verificar baseline visual, deduplicar internals.
|
||||||
|
|
||||||
|
### 13. Tests Puros
|
||||||
|
|
||||||
|
Añadir tests para:
|
||||||
|
|
||||||
|
- Normalizadores.
|
||||||
|
- Pack registry.
|
||||||
|
- Shortcut parsing.
|
||||||
|
- Country resolving.
|
||||||
|
- Bracket round formatting.
|
||||||
|
|
||||||
|
### 14. Verificación Visual
|
||||||
|
|
||||||
|
Añadir Playwright para overlays principales:
|
||||||
|
|
||||||
|
- `scoreboard`.
|
||||||
|
- `scoreboard-2xko`.
|
||||||
|
- `commentary`.
|
||||||
|
|
||||||
|
Debe validar screenshots o checks visuales estables antes de tocar CSS sensible.
|
||||||
|
|
||||||
|
## Clasificación de Trabajo
|
||||||
|
|
||||||
|
| Categoría | Incluye |
|
||||||
|
| --- | --- |
|
||||||
|
| Automatizable | Moves/imports, schema generation, lint autofix, snapshots. |
|
||||||
|
| Reescritura controlada | Packs y registry runtime. |
|
||||||
|
| División | `Players.vue`, `Settings.vue`, overlays. |
|
||||||
|
| Conservar | Quasar UI, Pinia, schemas, `oauth-server`, concepto de `store-sync`, layout visual de overlays. |
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Phase 1 Summary
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Executed the base architecture phase only. This phase focused on structure, boundaries, shared contracts and compatibility without changing UX, overlay visuals or large feature logic.
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- Added the `src/nodecg` boundary:
|
||||||
|
- `src/nodecg/browser`
|
||||||
|
- `src/nodecg/extension`
|
||||||
|
- centralized `replicantNames`
|
||||||
|
- centralized `messageNames`
|
||||||
|
- Moved browser replicant access out of `src/browser_shared`.
|
||||||
|
- Moved dashboard stores to `src/dashboard/stores`.
|
||||||
|
- Moved pure country helpers to `src/shared/domain/players`.
|
||||||
|
- Moved pack config and pack types to `src/shared/domain/packs`.
|
||||||
|
- Added pack replicant schemas:
|
||||||
|
- `installedPacks`
|
||||||
|
- `packRegistry`
|
||||||
|
- `downloadStates`
|
||||||
|
- `availableUpdates`
|
||||||
|
- Added generated TypeScript declarations for the new pack schemas.
|
||||||
|
- Removed dead example code:
|
||||||
|
- `exampleReplicant` schema/type
|
||||||
|
- `ExampleType`
|
||||||
|
- `src/extension/example.ts`
|
||||||
|
- Removed redundant stale JS files from `src/shared`.
|
||||||
|
- Updated extension bootstrap to use an explicit NodeCG context boundary.
|
||||||
|
- Routed browser messages through `src/nodecg/browser/messages.ts`.
|
||||||
|
- Routed extension message registration through `src/nodecg/extension/messages.ts`.
|
||||||
|
- Routed pack replicant creation through NodeCG pack boundary services.
|
||||||
|
- Updated build config so generated NodeCG/shared extension outputs are ignored and cleaned.
|
||||||
|
|
||||||
|
## Preserved
|
||||||
|
|
||||||
|
- No overlay UX, CSS, SVG, layout or animation logic was intentionally changed.
|
||||||
|
- No large dashboard view was split or rewritten.
|
||||||
|
- `pack-manager.ts` behavior was preserved; only imports, types, replicant/message names and boundaries were normalized.
|
||||||
|
- Public NodeCG message names were kept unchanged for compatibility.
|
||||||
|
- Public replicant names and defaults were kept unchanged.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm.cmd exec vue-tsc -p tsconfig.browser.json --noEmit`: passed.
|
||||||
|
- `pnpm.cmd exec tsc -b tsconfig.extension.json --pretty false`: passed.
|
||||||
|
- `pnpm.cmd exec eslint`: passed with 0 errors and existing Vue formatting warnings.
|
||||||
|
- `pnpm.cmd run build`: passed.
|
||||||
|
|
||||||
|
## Remaining For Later Phases
|
||||||
|
|
||||||
|
- Controlled rewrite of `pack-manager.ts`.
|
||||||
|
- Controlled rewrite of `usePackRegistry` and `fighting-characters.ts`.
|
||||||
|
- Formal provider module split for Start.gg and Challonge.
|
||||||
|
- Splitting `Players.vue` and `Settings.vue`.
|
||||||
|
- Overlay view models and visual baseline work.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Phase 2 Summary
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Executed the state and replicants phase only.
|
||||||
|
|
||||||
|
This phase focused on isolating state logic, normalizing Pinia stores, encapsulating browser-side replicant access, and moving side effects behind services without changing UX, visual design, overlay CSS, or public NodeCG contracts.
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- Added pure state/domain modules:
|
||||||
|
- `src/shared/domain/scoreboard`
|
||||||
|
- `src/shared/domain/commentary`
|
||||||
|
- `src/shared/domain/graphics`
|
||||||
|
- `src/shared/domain/players/state.ts`
|
||||||
|
- `src/shared/domain/packs/characters.ts`
|
||||||
|
- Moved normalization and pure state transitions out of dashboard stores.
|
||||||
|
- Replaced direct dashboard replicant imports with `src/dashboard/services/replicant-state-service.ts`.
|
||||||
|
- Added `useGraphicsSettingsStore` and moved dashboard graphics skin writes through the store.
|
||||||
|
- Reworked scoreboard, players and commentary stores to use shared domain normalizers and service-based replicant sync.
|
||||||
|
- Replaced the pack registry singleton composable with a normalized `usePacksStore`.
|
||||||
|
- Moved pack replicant listeners and NodeCG pack messages into `src/dashboard/services/pack-service.ts`.
|
||||||
|
- Removed Vue reactivity and mutable pack registration from `src/shared/fighting-characters.ts`.
|
||||||
|
- Modeled installed pack manifests as explicit store state instead of hidden module state.
|
||||||
|
- Centralized registry auto-refresh timer in the packs store.
|
||||||
|
- Routed integration NodeCG messages through `src/dashboard/services/integration-message-service.ts`.
|
||||||
|
- Added `src/graphics/shared/services/replicated-state.ts` so graphics read replicants through a service layer.
|
||||||
|
- Removed the redundant `src/dashboard/stores/store-sync.ts`.
|
||||||
|
|
||||||
|
## Preserved
|
||||||
|
|
||||||
|
- Public replicant names were unchanged.
|
||||||
|
- Public message names were unchanged.
|
||||||
|
- Existing dashboard UX was preserved.
|
||||||
|
- Overlay markup, CSS, positioning and animation logic were not intentionally changed.
|
||||||
|
- The existing `usePackRegistry` import path remains as a compatibility wrapper over the packs store.
|
||||||
|
- The legacy `src/shared/fighting-characters.ts` path remains as a compatibility export, but no longer owns mutable runtime state.
|
||||||
|
|
||||||
|
## Realtime Flow After This Phase
|
||||||
|
|
||||||
|
```text
|
||||||
|
schemas
|
||||||
|
-> nodecg/browser
|
||||||
|
-> dashboard services / graphics services
|
||||||
|
-> Pinia stores or overlay computed state
|
||||||
|
-> components
|
||||||
|
```
|
||||||
|
|
||||||
|
Pack runtime flow:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pack replicants
|
||||||
|
-> pack service
|
||||||
|
-> packs store
|
||||||
|
-> pack registry compatibility composable
|
||||||
|
-> game / character UI
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm.cmd exec vue-tsc -p tsconfig.browser.json --noEmit`: passed.
|
||||||
|
- `pnpm.cmd exec tsc -b tsconfig.extension.json --pretty false`: passed.
|
||||||
|
- `pnpm.cmd exec eslint`: passed with 0 errors and existing Vue formatting warnings.
|
||||||
|
- `pnpm.cmd run build`: passed.
|
||||||
|
- Searched dashboard, graphics and shared for direct NodeCG/message/replicant imports:
|
||||||
|
- remaining browser NodeCG access is contained in services and `nodecg/browser`.
|
||||||
|
- direct component/view replicant imports were removed.
|
||||||
|
- Searched for `any` in touched runtime areas:
|
||||||
|
- no new TypeScript `any` usage was added.
|
||||||
|
|
||||||
|
## Notes and Limits
|
||||||
|
|
||||||
|
- This phase did not split large views like `Players.vue` or `Settings.vue`.
|
||||||
|
- This phase did not refactor overlay internals beyond replacing direct replicant imports with a read service.
|
||||||
|
- This phase did not rewrite extension-side `pack-manager.ts`.
|
||||||
|
- This phase did not rename public messages to the future canonical names; compatibility was preserved.
|
||||||
|
- Existing Vue lint warnings remain formatting-only and were not addressed because they are outside this phase.
|
||||||
|
|
||||||
|
## Remaining For Later Phases
|
||||||
|
|
||||||
|
- Controlled rewrite of `pack-manager.ts`.
|
||||||
|
- Full split of `useIntegration` into provider clients, OAuth client, temporary players and import modules.
|
||||||
|
- Divide `Players.vue` and `Settings.vue`.
|
||||||
|
- Extract overlay view models and visual helpers after visual baseline.
|
||||||
|
- Add tests for pure normalizers and pack state derivations.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Session Handoff
|
||||||
|
|
||||||
|
Este handoff resume el contexto que debe asumir cualquier sesión futura antes de continuar el refactor. El análisis arquitectónico ya está hecho; no debe repetirse desde cero.
|
||||||
|
|
||||||
|
## Estado de la Sesión
|
||||||
|
|
||||||
|
- No se habían modificado archivos antes de crear esta documentación.
|
||||||
|
- Se leyó la estructura del proyecto, configs, schemas, extensión, dashboard, overlays y shared.
|
||||||
|
- `vue-tsc` pasa.
|
||||||
|
- `tsc` pasa.
|
||||||
|
- `lint` falla con 3 errores reales y 243 warnings de formato.
|
||||||
|
|
||||||
|
## Documentación Creada
|
||||||
|
|
||||||
|
| Documento | Propósito |
|
||||||
|
| --- | --- |
|
||||||
|
| `docs/refactor/ARCHITECTURE_AUDIT.md` | Diagnóstico del estado actual y riesgos. |
|
||||||
|
| `docs/refactor/MIGRATION_PLAN.md` | Orden secuencial de migración. |
|
||||||
|
| `docs/refactor/ARCHITECTURE_RULES.md` | Reglas accionables para implementación posterior. |
|
||||||
|
| `docs/refactor/TARGET_ARCHITECTURE.md` | Source of truth de la arquitectura objetivo. |
|
||||||
|
| `docs/refactor/SESSION_HANDOFF.md` | Contexto operativo para futuras sesiones. |
|
||||||
|
|
||||||
|
## Source of Truth
|
||||||
|
|
||||||
|
Para futuras sesiones:
|
||||||
|
|
||||||
|
1. Usar `TARGET_ARCHITECTURE.md` como referencia principal.
|
||||||
|
2. Aplicar siempre `ARCHITECTURE_RULES.md`.
|
||||||
|
3. Ejecutar `MIGRATION_PLAN.md` en orden.
|
||||||
|
4. Consultar `ARCHITECTURE_AUDIT.md` solo para entender el diagnóstico original.
|
||||||
|
|
||||||
|
## Próximo Paso Recomendado
|
||||||
|
|
||||||
|
El siguiente paso técnico, cuando se decida continuar, es iniciar el Paso 1 del plan:
|
||||||
|
|
||||||
|
- Congelar comportamiento.
|
||||||
|
- Capturar screenshots de overlays.
|
||||||
|
- Crear fixtures de replicants.
|
||||||
|
- Registrar baseline de build, typecheck y lint.
|
||||||
|
|
||||||
|
No empezar moviendo código antes de tener esa baseline.
|
||||||
|
|
||||||
|
## Riesgos a Recordar
|
||||||
|
|
||||||
|
- El sistema de packs es el área de mayor riesgo.
|
||||||
|
- Los overlays son sensibles a cambios visuales y deben tocarse al final.
|
||||||
|
- La frontera NodeCG debe centralizarse antes de reescribir features.
|
||||||
|
- Los replicants de packs deben formalizarse con schemas antes de limpiar runtime.
|
||||||
|
- `Players.vue` y `Settings.vue` deben dividirse, no reescribirse desde cero.
|
||||||
|
|
||||||
|
## Checks Conocidos
|
||||||
|
|
||||||
|
| Check | Resultado |
|
||||||
|
| --- | --- |
|
||||||
|
| `vue-tsc` | Pasa. |
|
||||||
|
| `tsc` | Pasa. |
|
||||||
|
| `lint` | Falla con 3 errores reales. |
|
||||||
|
|
||||||
|
Errores lint reales conocidos:
|
||||||
|
|
||||||
|
- `_id` en `Players.vue`.
|
||||||
|
- `_config` en `startgg.ts`.
|
||||||
|
- `_config` en `challonge.ts`.
|
||||||
|
|
||||||
|
Los demás avisos conocidos son warnings de formato Vue.
|
||||||
|
|
||||||
|
## Instrucción para Futuras Sesiones
|
||||||
|
|
||||||
|
No reanalizar el proyecto desde cero salvo que el código haya cambiado de forma sustancial. Continuar desde estos documentos y ejecutar el plan en orden.
|
||||||
|
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Target Architecture
|
||||||
|
|
||||||
|
Este documento es la source of truth para la arquitectura objetivo del refactor. Las futuras sesiones deben alinearse con esta estructura y con las reglas de `ARCHITECTURE_RULES.md`.
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
|
||||||
|
Crear una arquitectura simple y realista que:
|
||||||
|
|
||||||
|
- Centralice la frontera NodeCG.
|
||||||
|
- Centralice contratos realtime.
|
||||||
|
- Separe dominio puro de UI y runtime.
|
||||||
|
- Permita añadir providers, packs y skins sin duplicación.
|
||||||
|
- Preserve el comportamiento visual de overlays durante la migración.
|
||||||
|
|
||||||
|
## Estructura Objetivo
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
shared/
|
||||||
|
schemas/
|
||||||
|
types/
|
||||||
|
domain/
|
||||||
|
scoreboard/
|
||||||
|
players/
|
||||||
|
commentary/
|
||||||
|
packs/
|
||||||
|
integrations/
|
||||||
|
utils/
|
||||||
|
nodecg/
|
||||||
|
browser/
|
||||||
|
replicants.ts
|
||||||
|
messages.ts
|
||||||
|
extension/
|
||||||
|
context.ts
|
||||||
|
replicants.ts
|
||||||
|
messages.ts
|
||||||
|
extension/
|
||||||
|
modules/
|
||||||
|
packs/
|
||||||
|
integrations/
|
||||||
|
startgg/
|
||||||
|
challonge/
|
||||||
|
oauth/
|
||||||
|
dashboard/
|
||||||
|
app/
|
||||||
|
features/
|
||||||
|
scoreboard/
|
||||||
|
players/
|
||||||
|
graphics/
|
||||||
|
settings/
|
||||||
|
integrations/
|
||||||
|
packs/
|
||||||
|
stores/
|
||||||
|
ui/
|
||||||
|
graphics/
|
||||||
|
shared/
|
||||||
|
composables/
|
||||||
|
view-models/
|
||||||
|
assets/
|
||||||
|
scoreboard/
|
||||||
|
scoreboard-2xko/
|
||||||
|
commentary/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
| Boundary | Puede hacer | No puede hacer |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `shared/domain` | Tipos, funciones puras, normalizadores, mapping. | Importar Vue, NodeCG o DOM. |
|
||||||
|
| `nodecg/browser` | Acceso browser a replicants y messages. | Contener lógica de negocio o UI. |
|
||||||
|
| `nodecg/extension` | Acceso server a `nodecg.Replicant`, `listenFor`, `mount` y logging. | Implementar lógica específica de features. |
|
||||||
|
| `dashboard/stores` | Estado de aplicación y sync con replicants. | Contener UI compleja. |
|
||||||
|
| `dashboard/features` | Componentes y composables por feature. | Acceder directamente a NodeCG. |
|
||||||
|
| `graphics/shared` | View models, helpers visuales compartidos, assets compartidos. | Cambiar contratos realtime. |
|
||||||
|
| `extension/modules` | Handlers y servicios pequeños registrados desde bootstrap. | Mezclar responsabilidades sin separación. |
|
||||||
|
|
||||||
|
## Flujo Realtime Objetivo
|
||||||
|
|
||||||
|
```text
|
||||||
|
schemas
|
||||||
|
-> generated types
|
||||||
|
-> nodecg/browser + nodecg/extension
|
||||||
|
-> dashboard stores / extension modules / graphics view models
|
||||||
|
```
|
||||||
|
|
||||||
|
Reglas del flujo:
|
||||||
|
|
||||||
|
- Todo replicant persistente o realtime tiene schema.
|
||||||
|
- Los tipos se generan desde schemas.
|
||||||
|
- Dashboard y graphics no crean replicants directamente.
|
||||||
|
- Extension modules no exponen replicants sin pasar por `nodecg/extension`.
|
||||||
|
|
||||||
|
## Replicants
|
||||||
|
|
||||||
|
### Fuente de Verdad
|
||||||
|
|
||||||
|
Los schemas son la fuente de verdad para todos los replicants.
|
||||||
|
|
||||||
|
### Replicants a Mantener
|
||||||
|
|
||||||
|
- `scoreboard`
|
||||||
|
- `players`
|
||||||
|
- `commentary`
|
||||||
|
- `graphicsSettings`
|
||||||
|
|
||||||
|
### Replicants de Packs a Formalizar
|
||||||
|
|
||||||
|
- `installedPacks`
|
||||||
|
- `packRegistry`
|
||||||
|
- `downloadStates`
|
||||||
|
- `availableUpdates`
|
||||||
|
|
||||||
|
### Replicants a Eliminar
|
||||||
|
|
||||||
|
- `exampleReplicant`
|
||||||
|
|
||||||
|
## Messages
|
||||||
|
|
||||||
|
Los messages deben estar namespaced por dominio.
|
||||||
|
|
||||||
|
| Dominio | Ejemplos |
|
||||||
|
| --- | --- |
|
||||||
|
| Packs | `packs:fetchRegistry`, `packs:download` |
|
||||||
|
| Start.gg | `integrations:startgg:createOAuthSession` |
|
||||||
|
| Challonge | `integrations:challonge:createOAuthSession` |
|
||||||
|
|
||||||
|
Los componentes y composables de feature no deben llamar `nodecg.sendMessage` directamente. Deben usar clientes o services definidos en el boundary browser.
|
||||||
|
|
||||||
|
## Shared Domain
|
||||||
|
|
||||||
|
`shared/domain` contiene lógica reusable sin runtime:
|
||||||
|
|
||||||
|
- `scoreboard`: normalización de estado, mapping de jugadores, derivaciones de marcador.
|
||||||
|
- `players`: normalizadores, import/export, validación ligera.
|
||||||
|
- `commentary`: estado y mapping de comentaristas.
|
||||||
|
- `packs`: manifests, registry, installed packs y derivaciones.
|
||||||
|
- `integrations`: tipos normalizados, parsing básico y modelos comunes.
|
||||||
|
|
||||||
|
## Extension Modules
|
||||||
|
|
||||||
|
La extensión debe registrarse desde un bootstrap explícito y delegar en módulos:
|
||||||
|
|
||||||
|
| Módulo | Responsabilidad |
|
||||||
|
| --- | --- |
|
||||||
|
| `packs` | Registry, downloads, disk store, static mount, replicant sync y handlers. |
|
||||||
|
| `integrations/startgg` | Cliente Start.gg, OAuth session polling y parsing. |
|
||||||
|
| `integrations/challonge` | Cliente Challonge, OAuth session polling y parsing. |
|
||||||
|
| `oauth` | Reuso de `oauth-server.ts` y flujos comunes OAuth. |
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
El dashboard se organiza por features:
|
||||||
|
|
||||||
|
- `scoreboard`
|
||||||
|
- `players`
|
||||||
|
- `graphics`
|
||||||
|
- `settings`
|
||||||
|
- `integrations`
|
||||||
|
- `packs`
|
||||||
|
|
||||||
|
Las vistas coordinan features. Los componentes implementan UI. Los stores mantienen estado y sincronización.
|
||||||
|
|
||||||
|
## Graphics
|
||||||
|
|
||||||
|
Los overlays deben leer estado mediante view models:
|
||||||
|
|
||||||
|
- `useScoreboardOverlayViewModel`
|
||||||
|
- `useCommentaryOverlayViewModel`
|
||||||
|
|
||||||
|
`graphics/shared` contiene:
|
||||||
|
|
||||||
|
- Composables visuales.
|
||||||
|
- View models.
|
||||||
|
- Helpers de flags.
|
||||||
|
- Helpers de score animation.
|
||||||
|
- Helpers de text fitting.
|
||||||
|
- Assets compartidos.
|
||||||
|
|
||||||
|
El layout visual existente se conserva hasta tener verificación visual estable.
|
||||||
|
|
||||||
|
## Naming Canónico
|
||||||
|
|
||||||
|
| Tipo | Convención | Ejemplo |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Replicant | `camelCase` | `graphicsSettings` |
|
||||||
|
| Replicant constants | `replicantNames` | `replicantNames.graphicsSettings` |
|
||||||
|
| Message | `<domain>:<action>` | `packs:download` |
|
||||||
|
| Integration message | `integrations:<provider>:<action>` | `integrations:startgg:createOAuthSession` |
|
||||||
|
| Store | `use<Feature>Store` | `usePlayersStore` |
|
||||||
|
| Service | `create<ServiceName>` | `createPackService` |
|
||||||
|
| Provider client | `create<Provider>Client` | `createStartggClient` |
|
||||||
|
| Overlay view model | `use<Overlay>OverlayViewModel` | `useScoreboardOverlayViewModel` |
|
||||||
|
|
||||||
|
## Arquitectura a Preservar
|
||||||
|
|
||||||
|
- Pinia como estado del dashboard.
|
||||||
|
- Quasar como UI principal.
|
||||||
|
- Schemas JSON como fuente de tipos.
|
||||||
|
- `syncStateWithReplicant` como concepto.
|
||||||
|
- `oauth-server.ts` como base reusable.
|
||||||
|
- `countries.ts` como utilidad encapsulada.
|
||||||
|
- Layout visual de overlays hasta completar verificación visual.
|
||||||
|
|
||||||
@@ -12,10 +12,11 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Pandipipas",
|
"author": "Pandipipas",
|
||||||
|
"packageManager": "pnpm@11.0.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"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 ./nodecg && trash ./node_modules/.vite && trash ./shared/domain && 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",
|
||||||
@@ -26,8 +27,8 @@
|
|||||||
"@eslint/js": "^9.39.0",
|
"@eslint/js": "^9.39.0",
|
||||||
"@quasar/extras": "^1.17.0",
|
"@quasar/extras": "^1.17.0",
|
||||||
"@quasar/vite-plugin": "^1.10.0",
|
"@quasar/vite-plugin": "^1.10.0",
|
||||||
"@tsconfig/node22": "^22.0.2",
|
"@tsconfig/node24": "^24.0.0",
|
||||||
"@types/node": "^22.18.13",
|
"@types/node": "^24.0.0",
|
||||||
"@unhead/vue": "^2.0.19",
|
"@unhead/vue": "^2.0.19",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"@vue/eslint-config-typescript": "^14.6.0",
|
"@vue/eslint-config-typescript": "^14.6.0",
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
|
'@vaadin/vaadin-usage-statistics': true
|
||||||
|
better-sqlite3: true
|
||||||
|
esbuild: true
|
||||||
|
msgpackr-extract: true
|
||||||
|
vue-demi: true
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"installedVersion": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"latestVersion": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["installedVersion", "latestVersion"]
|
||||||
|
},
|
||||||
|
"default": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["idle", "fetching-manifest", "downloading", "done", "error"]
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["status", "progress"]
|
||||||
|
},
|
||||||
|
"default": {}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"exampleProperty": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "exampleString"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"exampleProperty"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"schemaVersion": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"packs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string" },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"version": { "type": "string" },
|
||||||
|
"totalSizeBytes": { "type": "number" },
|
||||||
|
"logoPath": { "type": "string" },
|
||||||
|
"characterCount": { "type": "integer" },
|
||||||
|
"palette": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"start": { "type": "string" },
|
||||||
|
"end": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["start", "end"]
|
||||||
|
},
|
||||||
|
"bundled": { "type": "boolean" }
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"version",
|
||||||
|
"totalSizeBytes",
|
||||||
|
"logoPath",
|
||||||
|
"characterCount",
|
||||||
|
"palette",
|
||||||
|
"bundled"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["schemaVersion", "updatedAt", "packs"],
|
||||||
|
"default": null
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { useReplicant } from 'nodecg-vue-composable';
|
|
||||||
import type { Schemas } from '../types';
|
|
||||||
|
|
||||||
// YOU MUST CHANGE THIS TO YOUR BUNDLE'S NAME!
|
|
||||||
const thisBundle = 'scoreko-dev';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is where you can declare all of your replicants to import easily into other (browser based) files.
|
|
||||||
* "useReplicant" is a helper composable to make accessing/modifying replicants easier.
|
|
||||||
* For more information see https://github.com/Dan-Shields/nodecg-vue-composable
|
|
||||||
*/
|
|
||||||
export const exampleReplicant = useReplicant<Schemas.ExampleReplicant>('exampleReplicant', thisBundle);
|
|
||||||
export const playersReplicant = useReplicant<Schemas.Players>('players', thisBundle);
|
|
||||||
export const scoreboardReplicant = useReplicant<Schemas.Scoreboard>('scoreboard', thisBundle);
|
|
||||||
export const graphicsSettingsReplicant = useReplicant<Schemas.GraphicsSettings>('graphicsSettings', thisBundle);
|
|
||||||
|
|
||||||
export const commentaryReplicant = useReplicant<Schemas.Commentary>('commentary', thisBundle);
|
|
||||||
@@ -2,35 +2,6 @@
|
|||||||
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',
|
||||||
@@ -38,15 +9,6 @@ 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,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, watch } from 'vue';
|
import { computed, 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',
|
||||||
@@ -25,7 +27,7 @@ const stageOptions = [
|
|||||||
const bracketSideOptions = [
|
const bracketSideOptions = [
|
||||||
{ label: 'None', value: '' },
|
{ label: 'None', value: '' },
|
||||||
{ label: 'Winners', value: 'Winners' },
|
{ label: 'Winners', value: 'Winners' },
|
||||||
{ label: 'Loosers', value: 'Loosers' },
|
{ label: 'Losers', value: 'Losers' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const stage = ref(stageOptions[0]);
|
const stage = ref(stageOptions[0]);
|
||||||
@@ -34,6 +36,14 @@ 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) {
|
||||||
@@ -73,12 +83,12 @@ const updateRound = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (customActive.value) {
|
if (customActive.value) {
|
||||||
scoreboardStore.scoreboard.round = customText.value.trim();
|
scoreboardStore.setRound(customText.value.trim());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = bracketSide.value ? `${bracketSide.value} ` : '';
|
const prefix = bracketSide.value ? `${bracketSide.value} ` : '';
|
||||||
scoreboardStore.scoreboard.round = `${prefix}${stage.value}`.trim();
|
scoreboardStore.setRound(`${prefix}${stage.value}`.trim());
|
||||||
};
|
};
|
||||||
|
|
||||||
watch([stage, bracketSide, customText, customActive], updateRound);
|
watch([stage, bracketSide, customText, customActive], updateRound);
|
||||||
@@ -90,8 +100,14 @@ watch(customActive, (value) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(customText, (value) => {
|
watch(customText, (value) => {
|
||||||
|
if (customDeactivateTimer) {
|
||||||
|
clearTimeout(customDeactivateTimer);
|
||||||
|
}
|
||||||
if (!value.trim()) {
|
if (!value.trim()) {
|
||||||
customActive.value = false;
|
customDeactivateTimer = setTimeout(() => {
|
||||||
|
customActive.value = false;
|
||||||
|
customDeactivateTimer = null;
|
||||||
|
}, 600);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,6 +128,7 @@ 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"
|
||||||
/>
|
/>
|
||||||
@@ -119,6 +136,7 @@ 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
|
||||||
@@ -129,6 +147,7 @@ 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
|
||||||
@@ -138,6 +157,17 @@ 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>
|
||||||
@@ -183,4 +213,36 @@ 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,8 +1,59 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { stripTwitterPrefix } from '../../../shared/domain/commentary';
|
||||||
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 handleLeftTwitterInput(value: string | number | null) {
|
||||||
|
commentaryStore.leftCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRightTwitterInput(value: string | number | null) {
|
||||||
|
commentaryStore.rightCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Clear ---
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
commentaryStore.clearCommentary();
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -14,7 +65,9 @@ const commentaryStore = useCommentaryStore();
|
|||||||
</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')"
|
||||||
@@ -27,23 +80,58 @@ const commentaryStore = useCommentaryStore();
|
|||||||
</QInput>
|
</QInput>
|
||||||
|
|
||||||
<QInput
|
<QInput
|
||||||
v-model="commentaryStore.leftCommentatorTwitter"
|
:model-value="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>
|
||||||
|
|
||||||
<QBtn
|
<!-- Center controls -->
|
||||||
flat
|
<div class="commentary-panel__center-controls">
|
||||||
dense
|
<QBtn
|
||||||
round
|
flat
|
||||||
icon="swap_horiz"
|
dense
|
||||||
class="commentary-panel__swap-btn"
|
round
|
||||||
@click="commentaryStore.swapCommentators"
|
icon="swap_horiz"
|
||||||
/>
|
class="commentary-panel__swap-btn"
|
||||||
|
@click="commentaryStore.swapCommentators"
|
||||||
|
>
|
||||||
|
<QTooltip anchor="top middle" self="bottom middle">
|
||||||
|
{{ t('commentarySwap') }}
|
||||||
|
</QTooltip>
|
||||||
|
</QBtn>
|
||||||
|
|
||||||
|
<QBtn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
round
|
||||||
|
icon="restart_alt"
|
||||||
|
class="commentary-panel__clear-btn"
|
||||||
|
:disable="!isAnythingFilled"
|
||||||
|
@click="clearAll"
|
||||||
|
>
|
||||||
|
<QTooltip anchor="top middle" self="bottom middle">
|
||||||
|
{{ t('commentaryClear') }}
|
||||||
|
</QTooltip>
|
||||||
|
</QBtn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Commentator 2 -->
|
||||||
<div class="commentary-panel__commentator">
|
<div class="commentary-panel__commentator">
|
||||||
|
|
||||||
<QInput
|
<QInput
|
||||||
v-model="commentaryStore.rightCommentator"
|
v-model="commentaryStore.rightCommentator"
|
||||||
:label="t('commentaryCommentator2')"
|
:label="t('commentaryCommentator2')"
|
||||||
@@ -56,11 +144,23 @@ const commentaryStore = useCommentaryStore();
|
|||||||
</QInput>
|
</QInput>
|
||||||
|
|
||||||
<QInput
|
<QInput
|
||||||
v-model="commentaryStore.rightCommentatorTwitter"
|
:model-value="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>
|
||||||
@@ -89,6 +189,35 @@ const commentaryStore = useCommentaryStore();
|
|||||||
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;
|
||||||
@@ -112,14 +241,27 @@ const commentaryStore = useCommentaryStore();
|
|||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentary-panel__swap-btn {
|
/* Handle preview */
|
||||||
color: #fff;
|
.commentary-panel__handle-preview {
|
||||||
opacity: 0.85;
|
margin-top: 4px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
padding-left: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentary-panel__swap-btn:hover {
|
.commentary-panel__preview-enter-active,
|
||||||
opacity: 1;
|
.commentary-panel__preview-leave-active {
|
||||||
text-shadow: 0 0 10px rgba(255, 255, 255, 0.45);
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentary-panel__preview-enter-from,
|
||||||
|
.commentary-panel__preview-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@@ -127,8 +269,9 @@ const commentaryStore = useCommentaryStore();
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentary-panel__swap-btn {
|
.commentary-panel__center-controls {
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// src/dashboard/scoreboard/components/GamePackDownloadDialog.vue
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Shown when the user clicks a game that is not yet installed.
|
||||||
|
// Displays size, character roster, and a download progress bar.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import { getPackLogoUrl } from '../../../shared/domain/packs/config';
|
||||||
|
import type { PackRegistryEntry } from '../../../shared/domain/packs/types';
|
||||||
|
import { usePackRegistry } from '../composables/usePackRegistry';
|
||||||
|
|
||||||
|
// ── Props / emits ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** v-model visibility */
|
||||||
|
modelValue: boolean;
|
||||||
|
/** The registry entry for the game the user wants to download/update */
|
||||||
|
packEntry: PackRegistryEntry | null;
|
||||||
|
/** When true the dialog shows "update" language and calls updatePack instead of downloadPack */
|
||||||
|
isUpdate?: boolean;
|
||||||
|
/** Version info shown in update mode */
|
||||||
|
updateInfo?: { installedVersion: string; latestVersion: string };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean];
|
||||||
|
/** Emitted after a successful download/update so the parent can switch to the game */
|
||||||
|
downloaded: [gameName: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// ── Pack registry ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const packRegistry = usePackRegistry();
|
||||||
|
|
||||||
|
// ── Computed ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const downloadState = computed(() =>
|
||||||
|
props.packEntry ? packRegistry.getDownloadState(props.packEntry.id) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDownloading = computed(() =>
|
||||||
|
downloadState.value?.status === 'downloading' ||
|
||||||
|
downloadState.value?.status === 'fetching-manifest',
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDone = computed(() => downloadState.value?.status === 'done');
|
||||||
|
const isError = computed(() => downloadState.value?.status === 'error');
|
||||||
|
|
||||||
|
const progress = computed(() => downloadState.value?.progress ?? 0);
|
||||||
|
|
||||||
|
// Pre-install: show logo directly from Gitea (pack not on disk yet).
|
||||||
|
// Update mode: pack is installed, serve from local /packs/ route.
|
||||||
|
const logoSrc = computed(() => {
|
||||||
|
if (!props.packEntry) return '';
|
||||||
|
if (props.isUpdate) return packRegistry.getLocalLogoUrl(props.packEntry.id);
|
||||||
|
return getPackLogoUrl(props.packEntry.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close automatically once download completes and emit so parent sets the game
|
||||||
|
watch(isDone, (done) => {
|
||||||
|
if (done && props.packEntry) {
|
||||||
|
emit('downloaded', props.packEntry.name);
|
||||||
|
emit('update:modelValue', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Actions ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const startDownload = () => {
|
||||||
|
if (!props.packEntry) return;
|
||||||
|
if (props.isUpdate) {
|
||||||
|
packRegistry.updatePack(props.packEntry.id);
|
||||||
|
} else {
|
||||||
|
packRegistry.downloadPack(props.packEntry.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => emit('update:modelValue', false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<QDialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
persistent
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<QCard
|
||||||
|
v-if="packEntry"
|
||||||
|
class="pack-download-dialog"
|
||||||
|
>
|
||||||
|
<!-- ── Header ─────────────────────────────────────────────────────── -->
|
||||||
|
<QCardSection class="pack-download-dialog__header">
|
||||||
|
<div class="pack-download-dialog__title-row">
|
||||||
|
<div>
|
||||||
|
<div class="text-h6 text-weight-bold">
|
||||||
|
{{ packEntry.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-5">
|
||||||
|
<template v-if="isUpdate && updateInfo">
|
||||||
|
Bundled v{{ updateInfo.installedVersion }} →
|
||||||
|
<span class="text-positive">v{{ updateInfo.latestVersion }}</span>
|
||||||
|
· {{ packEntry.characterCount }} personajes
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
v{{ packEntry.version }} · {{ packEntry.characterCount }} personajes ·
|
||||||
|
{{ packRegistry.formatBytes(packEntry.totalSizeBytes) }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<QBtn
|
||||||
|
v-if="!isDownloading"
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="close"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Banner: logo del juego con gradiente de fallback -->
|
||||||
|
<div
|
||||||
|
class="pack-download-dialog__banner"
|
||||||
|
:style="{
|
||||||
|
background: `linear-gradient(135deg, ${packEntry.palette.start}, ${packEntry.palette.end})`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="logoSrc"
|
||||||
|
:src="logoSrc"
|
||||||
|
class="pack-download-dialog__logo"
|
||||||
|
alt=""
|
||||||
|
@error="($event.target as HTMLImageElement).style.display = 'none'"
|
||||||
|
/>
|
||||||
|
<QIcon
|
||||||
|
:name="isUpdate ? 'upgrade' : 'sports_esports'"
|
||||||
|
size="40px"
|
||||||
|
color="white"
|
||||||
|
class="pack-download-dialog__banner-icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version info shown only in update mode -->
|
||||||
|
<div
|
||||||
|
v-if="isUpdate && updateInfo"
|
||||||
|
class="pack-download-dialog__version-badge"
|
||||||
|
>
|
||||||
|
<span class="text-grey-5">v{{ updateInfo.installedVersion }}</span>
|
||||||
|
<QIcon name="arrow_forward" size="14px" color="grey-5" />
|
||||||
|
<span class="text-positive text-weight-bold">v{{ updateInfo.latestVersion }}</span>
|
||||||
|
</div>
|
||||||
|
</QCardSection>
|
||||||
|
|
||||||
|
<QSeparator />
|
||||||
|
|
||||||
|
<!-- ── Progress / error ───────────────────────────────────────────── -->
|
||||||
|
<QCardSection
|
||||||
|
v-if="isDownloading || isDone || isError"
|
||||||
|
class="pack-download-dialog__progress-section"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isError"
|
||||||
|
class="pack-download-dialog__error"
|
||||||
|
>
|
||||||
|
<QIcon
|
||||||
|
name="error"
|
||||||
|
color="negative"
|
||||||
|
size="20px"
|
||||||
|
/>
|
||||||
|
<span>{{ downloadState?.error ?? 'Error desconocido' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="pack-download-dialog__progress-label">
|
||||||
|
<span>{{ isDownloading ? 'Descargando…' : '¡Listo!' }}</span>
|
||||||
|
<span>{{ progress }}%</span>
|
||||||
|
</div>
|
||||||
|
<QLinearProgress
|
||||||
|
:value="progress / 100"
|
||||||
|
:color="isDone ? 'positive' : 'primary'"
|
||||||
|
rounded
|
||||||
|
size="8px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</QCardSection>
|
||||||
|
|
||||||
|
<!-- ── Character list ─────────────────────────────────────────────── -->
|
||||||
|
<QCardSection class="pack-download-dialog__char-section">
|
||||||
|
<div class="text-caption text-grey-5 q-mb-sm">
|
||||||
|
Personajes incluidos
|
||||||
|
</div>
|
||||||
|
<!-- We only have the count in the registry entry; the full list lives
|
||||||
|
in the manifest. Show a placeholder grid until the registry has
|
||||||
|
a characters array (future enhancement: include it in registry.json). -->
|
||||||
|
<div class="pack-download-dialog__char-count">
|
||||||
|
<QIcon
|
||||||
|
name="sports_martial_arts"
|
||||||
|
size="16px"
|
||||||
|
/>
|
||||||
|
{{ packEntry.characterCount }} personajes en este pack
|
||||||
|
</div>
|
||||||
|
</QCardSection>
|
||||||
|
|
||||||
|
<QSeparator />
|
||||||
|
|
||||||
|
<!-- ── Actions ────────────────────────────────────────────────────── -->
|
||||||
|
<QCardActions
|
||||||
|
align="right"
|
||||||
|
class="q-pa-md"
|
||||||
|
>
|
||||||
|
<QBtn
|
||||||
|
v-if="!isDownloading"
|
||||||
|
flat
|
||||||
|
label="Cancelar"
|
||||||
|
color="grey-5"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
<QBtn
|
||||||
|
v-if="!isDownloading && !isDone"
|
||||||
|
unelevated
|
||||||
|
:label="isError ? 'Reintentar' : isUpdate ? 'Actualizar pack' : 'Descargar pack'"
|
||||||
|
:color="isUpdate ? 'positive' : 'primary'"
|
||||||
|
:icon="isUpdate ? 'upgrade' : 'download'"
|
||||||
|
@click="startDownload"
|
||||||
|
/>
|
||||||
|
<QBtn
|
||||||
|
v-if="isDownloading"
|
||||||
|
flat
|
||||||
|
:label="isUpdate ? 'Actualizando…' : 'Descargando…'"
|
||||||
|
:color="isUpdate ? 'positive' : 'primary'"
|
||||||
|
loading
|
||||||
|
disable
|
||||||
|
/>
|
||||||
|
</QCardActions>
|
||||||
|
</QCard>
|
||||||
|
</QDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pack-download-dialog {
|
||||||
|
width: 420px;
|
||||||
|
max-width: 95vw;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-download-dialog__header {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-download-dialog__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-download-dialog__banner {
|
||||||
|
position: relative;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-download-dialog__logo {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-download-dialog__banner-icon {
|
||||||
|
position: relative; /* above the logo */
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-download-dialog__progress-section {
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-download-dialog__progress-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-download-dialog__error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--q-negative);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-download-dialog__char-section {
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-download-dialog__char-count {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-download-dialog__version-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
<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) => {
|
||||||
|
scoreboardStore.setSideCharacter(props.side, 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>
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { inject, onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
|
||||||
|
import { usePackRegistry } from '../composables/usePackRegistry';
|
||||||
|
import { t } from '../i18n';
|
||||||
|
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||||
|
import GamePackDownloadDialog from './GamePackDownloadDialog.vue';
|
||||||
|
|
||||||
|
const scoreboardStore = useScoreboardStore();
|
||||||
|
const packRegistry = usePackRegistry();
|
||||||
|
|
||||||
|
const {
|
||||||
|
gameInput,
|
||||||
|
fightingGameOptions,
|
||||||
|
onGameFilter,
|
||||||
|
handleGameSelect,
|
||||||
|
pendingDownloadEntry,
|
||||||
|
showDownloadDialog,
|
||||||
|
} = inject(CHARACTER_GAME_KEY)!;
|
||||||
|
|
||||||
|
// Refresca el catálogo al montar y luego cada 15 segundos automáticamente.
|
||||||
|
// Si Gitea no está disponible se usa la caché persistida del replicante.
|
||||||
|
onMounted(() => {
|
||||||
|
packRegistry.startRegistryRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
packRegistry.stopRegistryRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
const adjustLeftScore = (delta: number) => {
|
||||||
|
scoreboardStore.adjustScore('left', delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
const adjustRightScore = (delta: number) => {
|
||||||
|
scoreboardStore.adjustScore('right', delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tras una descarga exitosa, activa el juego en el store. */
|
||||||
|
const onPackDownloaded = (gameName: string) => {
|
||||||
|
scoreboardStore.setGame(gameName);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Estado del diálogo de actualización ───────────────────────────────────────
|
||||||
|
const pendingUpdateEntry = ref<import('../../../shared/domain/packs/types').PackRegistryEntry | null>(null);
|
||||||
|
const pendingUpdateInfo = ref<{ installedVersion: string; latestVersion: string } | undefined>(undefined);
|
||||||
|
const showUpdateDialog = ref(false);
|
||||||
|
|
||||||
|
const openUpdateDialog = (opt: import('../../../shared/domain/packs/types').GameSelectOption, event: Event) => {
|
||||||
|
event.stopPropagation(); // evitar que el QItem cambie la selección
|
||||||
|
pendingUpdateEntry.value = opt.registryEntry;
|
||||||
|
pendingUpdateInfo.value = opt.updateInfo;
|
||||||
|
showUpdateDialog.value = true;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="scoreboard-preview__center">
|
||||||
|
<!--
|
||||||
|
v-model → :model-value + @update:model-value para interceptar la
|
||||||
|
selección de juegos no instalados antes de escribir en el store.
|
||||||
|
-->
|
||||||
|
<QSelect
|
||||||
|
:model-value="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"
|
||||||
|
@update:model-value="handleGameSelect"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<QIcon name="sports_esports" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Slot personalizado: muestra iconos de descarga o actualización según el estado -->
|
||||||
|
<template #option="scope">
|
||||||
|
<QItem
|
||||||
|
v-bind="scope.itemProps"
|
||||||
|
:class="{ 'pack-option--unavailable': !scope.opt.available }"
|
||||||
|
>
|
||||||
|
<QItemSection>
|
||||||
|
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
|
||||||
|
</QItemSection>
|
||||||
|
|
||||||
|
<!-- Icono de actualización disponible (pack instalado, versión nueva en repo) -->
|
||||||
|
<QItemSection
|
||||||
|
v-if="scope.opt.available && scope.opt.updateInfo"
|
||||||
|
side
|
||||||
|
>
|
||||||
|
<QBtn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
size="xs"
|
||||||
|
icon="upgrade"
|
||||||
|
color="positive"
|
||||||
|
@click="openUpdateDialog(scope.opt, $event)"
|
||||||
|
>
|
||||||
|
<QTooltip>
|
||||||
|
Actualización disponible:
|
||||||
|
v{{ scope.opt.updateInfo.installedVersion }} →
|
||||||
|
v{{ scope.opt.updateInfo.latestVersion }}
|
||||||
|
</QTooltip>
|
||||||
|
</QBtn>
|
||||||
|
</QItemSection>
|
||||||
|
|
||||||
|
<!-- Icono de descarga (pack no instalado) -->
|
||||||
|
<QItemSection
|
||||||
|
v-else-if="!scope.opt.available"
|
||||||
|
side
|
||||||
|
>
|
||||||
|
<QIcon
|
||||||
|
name="download"
|
||||||
|
size="16px"
|
||||||
|
color="grey-5"
|
||||||
|
>
|
||||||
|
<QTooltip>Pack no instalado — haz clic para descargarlo</QTooltip>
|
||||||
|
</QIcon>
|
||||||
|
</QItemSection>
|
||||||
|
</QItem>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Dialog de descarga — se abre automáticamente al seleccionar un juego no instalado -->
|
||||||
|
<GamePackDownloadDialog
|
||||||
|
v-model="showDownloadDialog"
|
||||||
|
:pack-entry="pendingDownloadEntry"
|
||||||
|
@downloaded="onPackDownloaded"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Dialog de actualización — se abre al hacer clic en el icono de upgrade -->
|
||||||
|
<GamePackDownloadDialog
|
||||||
|
v-model="showUpdateDialog"
|
||||||
|
:pack-entry="pendingUpdateEntry"
|
||||||
|
:is-update="true"
|
||||||
|
:update-info="pendingUpdateInfo"
|
||||||
|
@downloaded="onPackDownloaded"
|
||||||
|
/>
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Atenúa visualmente los juegos no instalados en el desplegable */
|
||||||
|
.pack-option--unavailable {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pack-option--unavailable:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
// src/dashboard/scoreboard/composables/useCharacterGame.ts
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Manages game selection and character state for both PlayerSidePanels.
|
||||||
|
// Must be called ONCE in ScoreboardPanel and provided via CHARACTER_GAME_KEY.
|
||||||
|
//
|
||||||
|
// Changes from original:
|
||||||
|
// - fightingGameOptions is now driven by the pack registry (allGameOptions)
|
||||||
|
// rather than a static hardcoded list. It falls back to bundled names
|
||||||
|
// while the registry loads.
|
||||||
|
// - Game selection is intercepted: selecting an unavailable game triggers
|
||||||
|
// the download dialog instead of updating the store.
|
||||||
|
// - pendingDownloadEntry / showDownloadDialog are exposed for ScoreCenterPanel.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { computed, ref, watch, type InjectionKey, type Ref } from 'vue';
|
||||||
|
import type { FightingCharacterOption } from '../../../shared/domain/packs/characters';
|
||||||
|
import type { GameSelectOption, PackRegistryEntry } from '../../../shared/domain/packs/types';
|
||||||
|
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||||
|
import { usePackRegistry } from './usePackRegistry';
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CharacterOption = FightingCharacterOption;
|
||||||
|
export type CharacterGameContext = ReturnType<typeof useCharacterGame>;
|
||||||
|
export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame');
|
||||||
|
|
||||||
|
// ── Composable ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function useCharacterGame() {
|
||||||
|
const scoreboardStore = useScoreboardStore();
|
||||||
|
const packRegistry = usePackRegistry();
|
||||||
|
|
||||||
|
// ── Game selector state ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const gameInput = ref('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game options surfaced to the QSelect.
|
||||||
|
* Populated from the pack registry when available; falls back to bundled games.
|
||||||
|
* GameSelectOption includes an `available` flag used to show the download icon.
|
||||||
|
*/
|
||||||
|
const fightingGameOptions = ref<GameSelectOption[]>([]);
|
||||||
|
|
||||||
|
// Keep fightingGameOptions in sync when the registry updates
|
||||||
|
watch(
|
||||||
|
packRegistry.allGameOptions,
|
||||||
|
(options) => {
|
||||||
|
fightingGameOptions.value = options;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Download dialog state ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Set when the user selects a game that isn't installed yet. */
|
||||||
|
const pendingDownloadEntry = ref<PackRegistryEntry | null>(null);
|
||||||
|
const showDownloadDialog = ref(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intercepting setter for the game selector.
|
||||||
|
* If the selected game is not available, opens the download dialog instead
|
||||||
|
* of writing to the store.
|
||||||
|
*/
|
||||||
|
const handleGameSelect = (gameName: string) => {
|
||||||
|
if (!gameName) {
|
||||||
|
scoreboardStore.setGame('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!packRegistry.isGameAvailable(gameName)) {
|
||||||
|
const entry = fightingGameOptions.value.find((o) => o.value === gameName)?.registryEntry ?? null;
|
||||||
|
pendingDownloadEntry.value = entry;
|
||||||
|
showDownloadDialog.value = true;
|
||||||
|
// Do NOT update the store — the game isn't installed
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scoreboardStore.setGame(gameName);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Character state ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const characterOptions = computed(() => {
|
||||||
|
return packRegistry.getCharactersByGame(scoreboardStore.scoreboard.game);
|
||||||
|
});
|
||||||
|
const leftCharacterOptions = ref<CharacterOption[]>([]);
|
||||||
|
const rightCharacterOptions = ref<CharacterOption[]>([]);
|
||||||
|
const leftCharacterInput = ref('');
|
||||||
|
const rightCharacterInput = ref('');
|
||||||
|
|
||||||
|
const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({});
|
||||||
|
|
||||||
|
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
|
||||||
|
? packRegistry.allGameOptions.value.filter((g) =>
|
||||||
|
g.label.toLowerCase().includes(needle),
|
||||||
|
)
|
||||||
|
: packRegistry.allGameOptions.value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 with the store
|
||||||
|
watch(
|
||||||
|
() => scoreboardStore.scoreboard.game,
|
||||||
|
(value) => {
|
||||||
|
const match = fightingGameOptions.value.find((o) => o.value === value);
|
||||||
|
gameInput.value = match?.label ?? value;
|
||||||
|
},
|
||||||
|
{ 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 = packRegistry.getCharactersByGame(newGame);
|
||||||
|
|
||||||
|
// If the game is set but has no options yet, the pack is still loading
|
||||||
|
// (installed pack whose manifest has not been loaded into the pack store yet).
|
||||||
|
// Bail out — the characterOptions watcher below will restore state
|
||||||
|
// once the pack becomes available.
|
||||||
|
if (newGame && options.length === 0) return;
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
|
||||||
|
if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) {
|
||||||
|
const defaults = packRegistry.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.setSideCharacter('left', nextLeft);
|
||||||
|
} else if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) {
|
||||||
|
scoreboardStore.setSideCharacter('left', '');
|
||||||
|
leftCharacterInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowed.has(nextRight)) {
|
||||||
|
scoreboardStore.setSideCharacter('right', nextRight);
|
||||||
|
} else if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) {
|
||||||
|
scoreboardStore.setSideCharacter('right', '');
|
||||||
|
rightCharacterInput.value = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// When an installed pack manifest becomes available, re-validate characters
|
||||||
|
// already present in the replicated scoreboard state.
|
||||||
|
watch(characterOptions, (options) => {
|
||||||
|
const game = scoreboardStore.scoreboard.game;
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
if (options.length === 0) return;
|
||||||
|
|
||||||
|
const allowed = new Set(options.map((o) => o.value));
|
||||||
|
leftCharacterOptions.value = options;
|
||||||
|
rightCharacterOptions.value = options;
|
||||||
|
|
||||||
|
const { leftCharacter, rightCharacter } = scoreboardStore.scoreboard;
|
||||||
|
|
||||||
|
if (leftCharacter && allowed.has(leftCharacter)) {
|
||||||
|
leftCharacterInput.value = options.find((o) => o.value === leftCharacter)?.label ?? '';
|
||||||
|
} else if (leftCharacter && !allowed.has(leftCharacter)) {
|
||||||
|
scoreboardStore.setSideCharacter('left', '');
|
||||||
|
leftCharacterInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rightCharacter && allowed.has(rightCharacter)) {
|
||||||
|
rightCharacterInput.value = options.find((o) => o.value === rightCharacter)?.label ?? '';
|
||||||
|
} else if (rightCharacter && !allowed.has(rightCharacter)) {
|
||||||
|
scoreboardStore.setSideCharacter('right', '');
|
||||||
|
rightCharacterInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Return ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Game selector
|
||||||
|
gameInput,
|
||||||
|
fightingGameOptions,
|
||||||
|
onGameFilter,
|
||||||
|
handleGameSelect,
|
||||||
|
// Download dialog
|
||||||
|
pendingDownloadEntry,
|
||||||
|
showDownloadDialog,
|
||||||
|
// Character state
|
||||||
|
leftCharacterOptions,
|
||||||
|
rightCharacterOptions,
|
||||||
|
leftCharacterInput,
|
||||||
|
rightCharacterInput,
|
||||||
|
leftCharacterImage,
|
||||||
|
rightCharacterImage,
|
||||||
|
onLeftCharacterFilter,
|
||||||
|
onRightCharacterFilter,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { getCountryLabel, getCountryOptions } from '../../../shared/domain/players/countries';
|
||||||
|
import { locale } from '../i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages filtered country options and the display input value
|
||||||
|
* for a single side of the scoreboard.
|
||||||
|
*
|
||||||
|
* @param getOverride - Getter returning the current country code override
|
||||||
|
*/
|
||||||
|
export function useCountryFilter(getOverride: () => string) {
|
||||||
|
const countryOptions = computed(() => getCountryOptions(locale.value));
|
||||||
|
const countryInput = ref('');
|
||||||
|
const filteredOptions = ref(countryOptions.value);
|
||||||
|
|
||||||
|
// Keep filtered list in sync when locale changes
|
||||||
|
watch(countryOptions, (opts) => {
|
||||||
|
filteredOptions.value = opts;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep display input in sync with the stored country code
|
||||||
|
watch(getOverride, (value) => {
|
||||||
|
countryInput.value = getCountryLabel(value, locale.value);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const onFilter = (value: string, update: (fn: () => void) => void) => {
|
||||||
|
update(() => {
|
||||||
|
const needle = value.toLowerCase().trim();
|
||||||
|
filteredOptions.value = needle
|
||||||
|
? countryOptions.value.filter((c) => c.label.toLowerCase().includes(needle))
|
||||||
|
: countryOptions.value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { countryInput, filteredOptions, onFilter };
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||||
|
import { sendIntegrationMessage } from '../../services/integration-message-service';
|
||||||
|
|
||||||
|
// ─── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 sendIntegrationMessage<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 sendIntegrationMessage<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 sendIntegrationMessage<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 sendIntegrationMessage<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>;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import type { InjectionKey, Ref } from 'vue';
|
||||||
|
import { usePacksStore } from '../../stores/packs';
|
||||||
|
import type { DefaultCharacterPair, FightingCharacterOption } from '../../../shared/domain/packs/characters';
|
||||||
|
import type {
|
||||||
|
GameSelectOption,
|
||||||
|
PackDownloadState,
|
||||||
|
PackRegistry,
|
||||||
|
PackUpdateInfo,
|
||||||
|
} from '../../../shared/domain/packs/types';
|
||||||
|
|
||||||
|
export interface PackRegistryContext {
|
||||||
|
registry: Ref<PackRegistry | null>;
|
||||||
|
installedPackIds: Ref<string[]>;
|
||||||
|
downloadStates: Ref<Record<string, PackDownloadState>>;
|
||||||
|
isGameAvailable: (gameName: string) => boolean;
|
||||||
|
getDownloadState: (packId: string) => PackDownloadState;
|
||||||
|
getCharactersByGame: (gameName: string) => FightingCharacterOption[];
|
||||||
|
getDefaultCharactersByGame: (gameName: string) => DefaultCharacterPair | undefined;
|
||||||
|
allGameOptions: Ref<GameSelectOption[]>;
|
||||||
|
fetchRegistry: () => void;
|
||||||
|
startRegistryRefresh: (intervalMs?: number) => void;
|
||||||
|
stopRegistryRefresh: () => void;
|
||||||
|
downloadPack: (packId: string) => void;
|
||||||
|
uninstallPack: (packId: string) => void;
|
||||||
|
updatePack: (packId: string) => void;
|
||||||
|
availableUpdates: Ref<Record<string, PackUpdateInfo>>;
|
||||||
|
updateCount: Ref<number>;
|
||||||
|
formatBytes: (bytes: number) => string;
|
||||||
|
getLocalLogoUrl: (packId: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PACK_REGISTRY_KEY: InjectionKey<PackRegistryContext> = Symbol('packRegistry');
|
||||||
|
|
||||||
|
export function usePackRegistry(): PackRegistryContext {
|
||||||
|
const packsStore = usePacksStore();
|
||||||
|
packsStore.initialize();
|
||||||
|
|
||||||
|
const {
|
||||||
|
registry,
|
||||||
|
installedPackIds,
|
||||||
|
downloadStates,
|
||||||
|
availableUpdates,
|
||||||
|
allGameOptions,
|
||||||
|
updateCount,
|
||||||
|
} = storeToRefs(packsStore);
|
||||||
|
|
||||||
|
return {
|
||||||
|
registry,
|
||||||
|
installedPackIds,
|
||||||
|
downloadStates,
|
||||||
|
isGameAvailable: packsStore.isGameAvailable,
|
||||||
|
getDownloadState: packsStore.getDownloadState,
|
||||||
|
getCharactersByGame: packsStore.getCharactersByGame,
|
||||||
|
getDefaultCharactersByGame: packsStore.getDefaultCharactersByGame,
|
||||||
|
allGameOptions,
|
||||||
|
fetchRegistry: packsStore.fetchRegistry,
|
||||||
|
startRegistryRefresh: packsStore.startRegistryRefresh,
|
||||||
|
stopRegistryRefresh: packsStore.stopRegistryRefresh,
|
||||||
|
downloadPack: packsStore.downloadPack,
|
||||||
|
uninstallPack: packsStore.uninstallPack,
|
||||||
|
updatePack: packsStore.updatePack,
|
||||||
|
availableUpdates,
|
||||||
|
updateCount,
|
||||||
|
formatBytes: packsStore.formatBytes,
|
||||||
|
getLocalLogoUrl: packsStore.getLocalLogoUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import { computed, ref, watch, watchEffect } from 'vue';
|
||||||
|
import { useScoreboardStore } from '../../stores/scoreboard';
|
||||||
|
import { usePlayersStore } from '../../stores/players';
|
||||||
|
import type { Schemas } from '../../../types';
|
||||||
|
import { createPlayerId, normalizePlayerName } from '../../../shared/domain/players/state';
|
||||||
|
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)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) => {
|
||||||
|
scoreboardStore.setSidePlayerId(side, v);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nameOverride = computed({
|
||||||
|
get: () => (isLeft ? scoreboardStore.scoreboard.leftNameOverride : scoreboardStore.scoreboard.rightNameOverride),
|
||||||
|
set: (v) => {
|
||||||
|
scoreboardStore.setSideNameOverride(side, v);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamOverride = computed({
|
||||||
|
get: () => (isLeft ? scoreboardStore.scoreboard.leftTeamOverride : scoreboardStore.scoreboard.rightTeamOverride),
|
||||||
|
set: (v) => {
|
||||||
|
scoreboardStore.setSideTeamOverride(side, v);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const countryOverride = computed({
|
||||||
|
get: () => (isLeft ? scoreboardStore.scoreboard.leftCountryOverride : scoreboardStore.scoreboard.rightCountryOverride),
|
||||||
|
set: (v) => {
|
||||||
|
scoreboardStore.setSideCountryOverride(side, 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 = normalizePlayerName(name);
|
||||||
|
return Boolean(normalized)
|
||||||
|
&& Object.values(playersStore.players).some(
|
||||||
|
(p) => normalizePlayerName(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,6 +24,14 @@ 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;
|
||||||
@@ -53,6 +61,8 @@ 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;
|
||||||
@@ -61,14 +71,21 @@ 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;
|
||||||
@@ -84,6 +101,8 @@ 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';
|
||||||
@@ -95,28 +114,42 @@ const messages: Record<Locale, Translations> = {
|
|||||||
menuGraphics: 'Graphics',
|
menuGraphics: 'Graphics',
|
||||||
menuSettings: 'Settings',
|
menuSettings: 'Settings',
|
||||||
menuAbout: 'About',
|
menuAbout: 'About',
|
||||||
|
|
||||||
|
// ── Settings ────────────────────────────────────────────────────────────
|
||||||
settingsTitle: 'Settings',
|
settingsTitle: 'Settings',
|
||||||
settingsDescription: 'Dashboard and bundle configuration.',
|
settingsDescription: 'Dashboard and bundle settings.',
|
||||||
settingsLanguageLabel: 'Language',
|
settingsLanguageLabel: 'Language',
|
||||||
settingsLanguageHint: 'Choose the dashboard language.',
|
settingsLanguageHint: 'Choose the dashboard language.',
|
||||||
settingsShortcutTitle: 'Keyboard shortcuts',
|
settingsShortcutTitle: 'Keyboard shortcuts',
|
||||||
settingsShortcutDescription: 'Configure quick keys to update the score for each side.',
|
settingsShortcutDescription: 'Configure keyboard shortcuts to update each side’s score.',
|
||||||
settingsShortcutLeftIncrementLabel: 'P1 score +1',
|
settingsShortcutLeftIncrementLabel: 'P1 score +1',
|
||||||
settingsShortcutLeftIncrementHint: 'Increases left player score by one.',
|
settingsShortcutLeftIncrementHint: 'Increases the left player’s score by one.',
|
||||||
settingsShortcutLeftDecrementLabel: 'P1 score -1',
|
settingsShortcutLeftDecrementLabel: 'P1 score -1',
|
||||||
settingsShortcutLeftDecrementHint: 'Decreases left player score by one.',
|
settingsShortcutLeftDecrementHint: 'Decreases the left player’s score by one.',
|
||||||
settingsShortcutRightIncrementLabel: 'P2 score +1',
|
settingsShortcutRightIncrementLabel: 'P2 score +1',
|
||||||
settingsShortcutRightIncrementHint: 'Increases right player score by one.',
|
settingsShortcutRightIncrementHint: 'Increases the right player’s score by one.',
|
||||||
settingsShortcutRightDecrementLabel: 'P2 score -1',
|
settingsShortcutRightDecrementLabel: 'P2 score -1',
|
||||||
settingsShortcutRightDecrementHint: 'Decreases right player score by one.',
|
settingsShortcutRightDecrementHint: 'Decreases the right player’s score by one.',
|
||||||
settingsShortcutReset: 'Reset shortcuts',
|
settingsShortcutReset: 'Reset shortcuts',
|
||||||
settingsShortcutRecordingHint: 'Press the desired shortcut now (example: Alt+1).',
|
settingsShortcutRecordingHint: 'Press the desired shortcut now (for 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',
|
||||||
@@ -124,11 +157,13 @@ 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 using NodeCG, Vue, and Quasar.',
|
aboutDescription: 'Dashboard for producing fighting game overlays with NodeCG, Vue, and Quasar.',
|
||||||
aboutFrameworkNodeCG: 'Framework NodeCG',
|
aboutFrameworkNodeCG: 'NodeCG framework',
|
||||||
aboutCollaboratorsTitle: 'Collaborators and acknowledgments',
|
aboutCollaboratorsTitle: 'Contributors 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',
|
||||||
@@ -137,32 +172,49 @@ 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 Electron\'s 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: 'Bundle graphics controls and status.',
|
graphicsDescription: 'Controls and status for bundle graphics.',
|
||||||
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: 'Commentary',
|
graphicsCommentary: 'Commentators',
|
||||||
graphicsSkinLabel: 'Skin',
|
graphicsSkinLabel: 'Theme',
|
||||||
commentaryTitle: 'Commentary',
|
graphicsCopied: 'URL copied to clipboard',
|
||||||
|
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 administrate. If you see "Client authentication failed", verify your config uses the Client ID/Secret from a start.gg OAuth App.',
|
playersStartggHelp: 'Connect via OAuth (recommended) or paste your personal token to load tournaments you created or manage.',
|
||||||
playersConnectStartgg: 'Connect with start.gg',
|
playersConnectStartgg: 'Connect with start.gg',
|
||||||
playersConnected: 'Connected',
|
playersConnected: 'Connected',
|
||||||
playersUsePersonalApi: 'Use personal API',
|
playersUsePersonalApi: 'Use personal token',
|
||||||
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.',
|
||||||
@@ -171,31 +223,48 @@ 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',
|
||||||
menuSettings: 'Configuración',
|
menuSettings: 'Configuración',
|
||||||
menuAbout: 'Acerca de',
|
menuAbout: 'Acerca de',
|
||||||
|
|
||||||
|
// ── Settings ────────────────────────────────────────────────────────────
|
||||||
settingsTitle: 'Configuración',
|
settingsTitle: 'Configuración',
|
||||||
settingsDescription: 'Configuración del dashboard y del bundle.',
|
settingsDescription: 'Configuración del panel 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 teclas rápidas para actualizar el score de cada lado.',
|
settingsShortcutDescription: 'Configura atajos para actualizar el marcador de cada lado.',
|
||||||
settingsShortcutLeftIncrementLabel: 'Score P1 +1',
|
settingsShortcutLeftIncrementLabel: 'Marcador P1 +1',
|
||||||
settingsShortcutLeftIncrementHint: 'Incrementa en uno el score del jugador izquierdo.',
|
settingsShortcutLeftIncrementHint: 'Incrementa en uno el marcador del jugador izquierdo.',
|
||||||
settingsShortcutLeftDecrementLabel: 'Score P1 -1',
|
settingsShortcutLeftDecrementLabel: 'Marcador P1 -1',
|
||||||
settingsShortcutLeftDecrementHint: 'Reduce en uno el score del jugador izquierdo.',
|
settingsShortcutLeftDecrementHint: 'Reduce en uno el marcador del jugador izquierdo.',
|
||||||
settingsShortcutRightIncrementLabel: 'Score P2 +1',
|
settingsShortcutRightIncrementLabel: 'Marcador P2 +1',
|
||||||
settingsShortcutRightIncrementHint: 'Incrementa en uno el score del jugador derecho.',
|
settingsShortcutRightIncrementHint: 'Incrementa en uno el marcador del jugador derecho.',
|
||||||
settingsShortcutRightDecrementLabel: 'Score P2 -1',
|
settingsShortcutRightDecrementLabel: 'Marcador P2 -1',
|
||||||
settingsShortcutRightDecrementHint: 'Reduce en uno el score del jugador derecho.',
|
settingsShortcutRightDecrementHint: 'Reduce en uno el marcador 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: 'Castellano',
|
languageSpanish: 'Español',
|
||||||
|
|
||||||
|
// ── Scoreboard ───────────────────────────────────────────────────────────
|
||||||
scoreboardUnassigned: '(Sin asignar)',
|
scoreboardUnassigned: '(Sin asignar)',
|
||||||
scoreboardLeft: 'Izquierda',
|
scoreboardLeft: 'Izquierda',
|
||||||
scoreboardRight: 'Derecha',
|
scoreboardRight: 'Derecha',
|
||||||
@@ -207,42 +276,61 @@ 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: 'Dashboard para producir overlays de juegos de lucha usando NodeCG, Vue y Quasar.',
|
aboutDescription: 'Panel 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 release del repositorio y la compara con la versión actual.',
|
aboutUpdateSystemDescription: 'Esta comprobación obtiene la última versión publicada del repositorio y la compara con la versión actual.',
|
||||||
aboutCheckUpdates: 'Buscar actualizaciones',
|
aboutCheckUpdates: 'Buscar actualizaciones',
|
||||||
aboutLatestRelease: 'Última release',
|
aboutLatestRelease: 'Última versión',
|
||||||
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 release.',
|
aboutUpToDate: 'Tu versión está actualizada con la última versión.',
|
||||||
aboutViewRelease: 'Ver release',
|
aboutViewRelease: 'Ver versión',
|
||||||
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: 'Scoreboard',
|
graphicsScoreboard: 'Marcador',
|
||||||
graphicsCommentary: 'Comentario',
|
graphicsCommentary: 'Comentaristas',
|
||||||
graphicsSkinLabel: 'Skin',
|
graphicsSkinLabel: 'Tema',
|
||||||
commentaryTitle: 'Comentario',
|
graphicsCopied: 'URL copiada al portapapeles',
|
||||||
|
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',
|
||||||
bracketTitle: 'Bracket',
|
commentaryTwitterMaxLength: 'Se excedió el límite de caracteres de Twitter',
|
||||||
|
commentaryTwitterInvalidChars: 'Caracteres inválidos en el texto de Twitter',
|
||||||
|
commentarySwap: 'Intercambiar comentaristas',
|
||||||
|
commentaryClear: 'Limpiar comentaristas',
|
||||||
|
|
||||||
|
// ── Bracket ──────────────────────────────────────────────────────────────
|
||||||
|
bracketTitle: 'Llave',
|
||||||
bracketStage: 'Etapa',
|
bracketStage: 'Etapa',
|
||||||
bracketSide: 'Lado del bracket',
|
bracketSide: 'Lado de la llave',
|
||||||
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. Si ves "Client authentication failed", revisa que tu configuración use el Client ID/Secret de una app OAuth de start.gg.',
|
playersStartggHelp: 'Conéctate por OAuth (recomendado) o pega tu token personal para cargar torneos que creaste o administras.',
|
||||||
playersConnectStartgg: 'Conectar con start.gg',
|
playersConnectStartgg: 'Conectar con start.gg',
|
||||||
playersConnected: 'Conectado',
|
playersConnected: 'Conectado',
|
||||||
playersUsePersonalApi: 'Usar API personal',
|
playersUsePersonalApi: 'Usar API personal',
|
||||||
@@ -254,16 +342,15 @@ 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') {
|
if (typeof window === 'undefined') return 'en';
|
||||||
return 'en';
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizeLocale(localStorage.getItem(STORAGE_KEY));
|
return normalizeLocale(localStorage.getItem(STORAGE_KEY));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -271,7 +358,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,87 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } 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';
|
||||||
|
|
||||||
const menuItems = computed(() => [
|
// ── Sidebar collapse ──────────────────────────────────────────────────────────
|
||||||
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' },
|
const LS_KEY = 'sidebar_collapsed';
|
||||||
{ label: t('menuPlayers'), to: '/players', icon: 'groups' },
|
const isCollapsed = ref(localStorage.getItem(LS_KEY) === 'true');
|
||||||
{ label: t('menuGraphics'), to: '/graphics', icon: 'collections' },
|
const drawerWidth = computed(() => (isCollapsed.value ? 60 : 220));
|
||||||
|
watch(isCollapsed, (val) => localStorage.setItem(LS_KEY, String(val)));
|
||||||
|
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' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const logoUrl = new URL('./image.png', import.meta.url).href;
|
// ── Online / Offline ──────────────────────────────────────────────────────────
|
||||||
const scoreboardStore = useScoreboardStore();
|
const isOnline = ref(navigator.onLine);
|
||||||
|
|
||||||
|
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)) {
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
return false;
|
return (
|
||||||
}
|
target.isContentEditable ||
|
||||||
|
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) ||
|
||||||
return target.isContentEditable
|
Boolean(target.closest('[contenteditable="true"]'))
|
||||||
|| ['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') {
|
if (isEditableTarget(event.target) || document.body.dataset.shortcutRecording === 'true') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { shortcuts } = shortcutSettingsStore;
|
const { shortcuts } = shortcutSettingsStore;
|
||||||
if (isShortcutMatch(event, shortcuts.leftIncrement)) {
|
if (isShortcutMatch(event, shortcuts.leftIncrement)) { scoreboardStore.leftScore += 1; event.preventDefault(); return; }
|
||||||
scoreboardStore.leftScore += 1;
|
if (isShortcutMatch(event, shortcuts.leftDecrement)) { scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore - 1); event.preventDefault(); return; }
|
||||||
event.preventDefault();
|
if (isShortcutMatch(event, shortcuts.rightIncrement)) { scoreboardStore.rightScore += 1; event.preventDefault(); return; }
|
||||||
return;
|
if (isShortcutMatch(event, shortcuts.rightDecrement)) { scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore - 1); event.preventDefault(); }
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
@@ -71,49 +91,117 @@ onUnmounted(() => {
|
|||||||
show-if-above
|
show-if-above
|
||||||
side="left"
|
side="left"
|
||||||
bordered
|
bordered
|
||||||
:width="220"
|
:width="drawerWidth"
|
||||||
class="sidebar-drawer"
|
class="sidebar-drawer"
|
||||||
>
|
>
|
||||||
<div class="sidebar-header q-pa-md">
|
<!-- ── Header ─────────────────────────────────────────── -->
|
||||||
<div class="row items-center no-wrap">
|
<div class="sidebar-header" :class="{ 'is-collapsed': isCollapsed }">
|
||||||
<img
|
<img :src="logoUrl" alt="Logo" class="sidebar-logo">
|
||||||
:src="logoUrl"
|
|
||||||
alt="Logo"
|
<Transition name="slide-fade">
|
||||||
class="sidebar-logo"
|
<div v-if="!isCollapsed" class="sidebar-title">
|
||||||
>
|
<span class="title-text">Scoreko-dev</span>
|
||||||
<div class="q-ml-sm">
|
<span v-if="appVersion" class="title-version">v{{ appVersion }}</span>
|
||||||
<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>
|
||||||
</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>
|
</div>
|
||||||
<QSeparator class="q-mb-sm" />
|
|
||||||
<QList>
|
<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
|
<QItem
|
||||||
v-for="item in menuItems"
|
v-for="item in mainItems"
|
||||||
:key="item.to"
|
:key="item.to"
|
||||||
clickable
|
clickable
|
||||||
:to="item.to"
|
:to="item.to"
|
||||||
exact
|
exact
|
||||||
active-class="sidebar-item-active"
|
active-class="sidebar-item-active"
|
||||||
|
:class="{ 'nav-item-collapsed': isCollapsed }"
|
||||||
>
|
>
|
||||||
<QItemSection avatar>
|
<QItemSection avatar>
|
||||||
<QIcon :name="item.icon" />
|
<QIcon :name="item.icon" size="sm" />
|
||||||
</QItemSection>
|
</QItemSection>
|
||||||
<QItemSection>
|
<QItemSection v-if="!isCollapsed">
|
||||||
<QItemLabel>{{ item.label }}</QItemLabel>
|
<QItemLabel>{{ item.label }}</QItemLabel>
|
||||||
</QItemSection>
|
</QItemSection>
|
||||||
|
<QTooltip
|
||||||
|
v-if="isCollapsed"
|
||||||
|
anchor="center right"
|
||||||
|
self="center left"
|
||||||
|
:offset="[10, 0]"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</QTooltip>
|
||||||
</QItem>
|
</QItem>
|
||||||
</QList>
|
</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>
|
||||||
</QDrawer>
|
</QDrawer>
|
||||||
|
|
||||||
<QPageContainer>
|
<QPageContainer>
|
||||||
@@ -123,28 +211,176 @@ 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 {
|
||||||
min-height: 72px;
|
display: flex;
|
||||||
|
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: 40px;
|
width: 36px;
|
||||||
height: 40px;
|
height: 36px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.by-label {
|
.sidebar-title {
|
||||||
font-size: 0.75rem;
|
display: flex;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.by-link {
|
/* ── Collapse button ──────────────────────────────────────────────────────── */
|
||||||
font-size: 0.75rem;
|
.collapse-btn {
|
||||||
color: #f50a64;
|
flex-shrink: 0;
|
||||||
text-decoration: none;
|
opacity: 0.4;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.by-link:hover {
|
/* ── Section separators ───────────────────────────────────────────────────── */
|
||||||
text-decoration: underline;
|
.section-sep {
|
||||||
|
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>
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import { defineStore } from 'pinia';
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { commentaryReplicant } from '../../../browser_shared/replicants';
|
|
||||||
import type { Schemas } from '../../../types';
|
|
||||||
import { syncStateWithReplicant } from './store-sync';
|
|
||||||
|
|
||||||
type Commentary = Schemas.Commentary;
|
|
||||||
|
|
||||||
const defaultCommentary: Commentary = {
|
|
||||||
leftCommentator: '',
|
|
||||||
leftCommentatorTwitter: '',
|
|
||||||
rightCommentator: '',
|
|
||||||
rightCommentatorTwitter: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeCommentary = (input: unknown): Commentary => {
|
|
||||||
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
|
||||||
return {
|
|
||||||
leftCommentator: typeof candidate.leftCommentator === 'string' ? candidate.leftCommentator : '',
|
|
||||||
leftCommentatorTwitter: typeof candidate.leftCommentatorTwitter === 'string' ? candidate.leftCommentatorTwitter : '',
|
|
||||||
rightCommentator: typeof candidate.rightCommentator === 'string' ? candidate.rightCommentator : '',
|
|
||||||
rightCommentatorTwitter: typeof candidate.rightCommentatorTwitter === 'string' ? candidate.rightCommentatorTwitter : '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCommentaryStore = defineStore('commentary', () => {
|
|
||||||
const commentary = ref<Commentary>({ ...defaultCommentary });
|
|
||||||
const replicant = commentaryReplicant;
|
|
||||||
syncStateWithReplicant(commentary, replicant, normalizeCommentary);
|
|
||||||
|
|
||||||
const leftCommentator = computed({
|
|
||||||
get: () => commentary.value.leftCommentator,
|
|
||||||
set: (value: string) => {
|
|
||||||
commentary.value = {
|
|
||||||
...commentary.value,
|
|
||||||
leftCommentator: value,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const leftCommentatorTwitter = computed({
|
|
||||||
get: () => commentary.value.leftCommentatorTwitter,
|
|
||||||
set: (value: string) => {
|
|
||||||
commentary.value = {
|
|
||||||
...commentary.value,
|
|
||||||
leftCommentatorTwitter: value,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rightCommentator = computed({
|
|
||||||
get: () => commentary.value.rightCommentator,
|
|
||||||
set: (value: string) => {
|
|
||||||
commentary.value = {
|
|
||||||
...commentary.value,
|
|
||||||
rightCommentator: value,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rightCommentatorTwitter = computed({
|
|
||||||
get: () => commentary.value.rightCommentatorTwitter,
|
|
||||||
set: (value: string) => {
|
|
||||||
commentary.value = {
|
|
||||||
...commentary.value,
|
|
||||||
rightCommentatorTwitter: value,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const swapCommentators = () => {
|
|
||||||
commentary.value = {
|
|
||||||
leftCommentator: commentary.value.rightCommentator,
|
|
||||||
leftCommentatorTwitter: commentary.value.rightCommentatorTwitter,
|
|
||||||
rightCommentator: commentary.value.leftCommentator,
|
|
||||||
rightCommentatorTwitter: commentary.value.leftCommentatorTwitter,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
commentary,
|
|
||||||
leftCommentator,
|
|
||||||
leftCommentatorTwitter,
|
|
||||||
rightCommentator,
|
|
||||||
rightCommentatorTwitter,
|
|
||||||
swapCommentators,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { defineStore } from 'pinia';
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { playersReplicant } from '../../../browser_shared/replicants';
|
|
||||||
import type { Schemas } from '../../../types';
|
|
||||||
import { readStorageSnapshot, syncStateWithReplicant } from './store-sync';
|
|
||||||
|
|
||||||
type PlayersMap = Schemas.Players;
|
|
||||||
type Player = PlayersMap[string];
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'scoreko-dev.players';
|
|
||||||
|
|
||||||
const normalizePlayer = (input: unknown): Player => {
|
|
||||||
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
|
||||||
return {
|
|
||||||
gamertag: typeof candidate.gamertag === 'string' ? candidate.gamertag : '',
|
|
||||||
name: typeof candidate.name === 'string' ? candidate.name : '',
|
|
||||||
team: typeof candidate.team === 'string' ? candidate.team : '',
|
|
||||||
country: typeof candidate.country === 'string' ? candidate.country : '',
|
|
||||||
twitter: typeof candidate.twitter === 'string' ? candidate.twitter : '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizePlayers = (input: unknown): PlayersMap => {
|
|
||||||
if (typeof input !== 'object' || input === null) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const result: PlayersMap = {};
|
|
||||||
Object.entries(input as Record<string, unknown>).forEach(([id, value]) => {
|
|
||||||
if (!id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
result[id] = normalizePlayer(value);
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const usePlayersStore = defineStore('players', () => {
|
|
||||||
const players = ref<PlayersMap>({});
|
|
||||||
const replicant = playersReplicant;
|
|
||||||
const storageSnapshot = readStorageSnapshot(STORAGE_KEY, normalizePlayers);
|
|
||||||
if (storageSnapshot) {
|
|
||||||
players.value = storageSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
syncStateWithReplicant(players, replicant, normalizePlayers, STORAGE_KEY);
|
|
||||||
|
|
||||||
const setPlayers = (value: PlayersMap) => {
|
|
||||||
players.value = normalizePlayers(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const upsertPlayer = (id: string, player: Player) => {
|
|
||||||
players.value = {
|
|
||||||
...players.value,
|
|
||||||
[id]: normalizePlayer(player),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePlayer = (id: string) => {
|
|
||||||
const next = { ...players.value };
|
|
||||||
delete next[id];
|
|
||||||
players.value = next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const rows = computed(() => Object.entries(players.value).map(([id, player]) => ({
|
|
||||||
id,
|
|
||||||
...player,
|
|
||||||
})));
|
|
||||||
|
|
||||||
return {
|
|
||||||
players,
|
|
||||||
rows,
|
|
||||||
setPlayers,
|
|
||||||
upsertPlayer,
|
|
||||||
removePlayer,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { defineStore } from 'pinia';
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { scoreboardReplicant } from '../../../browser_shared/replicants';
|
|
||||||
import type { Schemas } from '../../../types';
|
|
||||||
import { readStorageSnapshot, syncStateWithReplicant } from './store-sync';
|
|
||||||
|
|
||||||
type Scoreboard = Schemas.Scoreboard;
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'scoreko-dev.scoreboard';
|
|
||||||
|
|
||||||
const defaultScoreboard: Scoreboard = {
|
|
||||||
leftPlayerId: '',
|
|
||||||
rightPlayerId: '',
|
|
||||||
leftNameOverride: '',
|
|
||||||
rightNameOverride: '',
|
|
||||||
leftTeamOverride: '',
|
|
||||||
rightTeamOverride: '',
|
|
||||||
leftCountryOverride: '',
|
|
||||||
rightCountryOverride: '',
|
|
||||||
leftCharacter: '',
|
|
||||||
rightCharacter: '',
|
|
||||||
leftScore: 0,
|
|
||||||
rightScore: 0,
|
|
||||||
round: '',
|
|
||||||
game: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeScoreboard = (input: unknown): Scoreboard => {
|
|
||||||
const candidate = typeof input === 'object' && input !== null ? (input as Record<string, unknown>) : {};
|
|
||||||
return {
|
|
||||||
leftPlayerId: typeof candidate.leftPlayerId === 'string' ? candidate.leftPlayerId : '',
|
|
||||||
rightPlayerId: typeof candidate.rightPlayerId === 'string' ? candidate.rightPlayerId : '',
|
|
||||||
leftNameOverride: typeof candidate.leftNameOverride === 'string' ? candidate.leftNameOverride : '',
|
|
||||||
rightNameOverride: typeof candidate.rightNameOverride === 'string' ? candidate.rightNameOverride : '',
|
|
||||||
leftTeamOverride: typeof candidate.leftTeamOverride === 'string' ? candidate.leftTeamOverride : '',
|
|
||||||
rightTeamOverride: typeof candidate.rightTeamOverride === 'string' ? candidate.rightTeamOverride : '',
|
|
||||||
leftCountryOverride: typeof candidate.leftCountryOverride === 'string' ? candidate.leftCountryOverride : '',
|
|
||||||
rightCountryOverride: typeof candidate.rightCountryOverride === 'string' ? candidate.rightCountryOverride : '',
|
|
||||||
leftCharacter: typeof candidate.leftCharacter === 'string' ? candidate.leftCharacter : '',
|
|
||||||
rightCharacter: typeof candidate.rightCharacter === 'string' ? candidate.rightCharacter : '',
|
|
||||||
leftScore: typeof candidate.leftScore === 'number' ? Math.max(0, Math.floor(candidate.leftScore)) : 0,
|
|
||||||
rightScore: typeof candidate.rightScore === 'number' ? Math.max(0, Math.floor(candidate.rightScore)) : 0,
|
|
||||||
round: typeof candidate.round === 'string' ? candidate.round : '',
|
|
||||||
game: typeof candidate.game === 'string' ? candidate.game : '',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useScoreboardStore = defineStore('scoreboard', () => {
|
|
||||||
const scoreboard = ref<Scoreboard>({ ...defaultScoreboard });
|
|
||||||
const replicant = scoreboardReplicant;
|
|
||||||
const storageSnapshot = readStorageSnapshot(STORAGE_KEY, normalizeScoreboard);
|
|
||||||
if (storageSnapshot) {
|
|
||||||
scoreboard.value = storageSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
syncStateWithReplicant(scoreboard, replicant, normalizeScoreboard, STORAGE_KEY);
|
|
||||||
|
|
||||||
const setScoreboard = (value: Scoreboard) => {
|
|
||||||
scoreboard.value = normalizeScoreboard(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const swapPlayers = () => {
|
|
||||||
scoreboard.value = {
|
|
||||||
...scoreboard.value,
|
|
||||||
leftPlayerId: scoreboard.value.rightPlayerId,
|
|
||||||
rightPlayerId: scoreboard.value.leftPlayerId,
|
|
||||||
leftNameOverride: scoreboard.value.rightNameOverride,
|
|
||||||
rightNameOverride: scoreboard.value.leftNameOverride,
|
|
||||||
leftTeamOverride: scoreboard.value.rightTeamOverride,
|
|
||||||
rightTeamOverride: scoreboard.value.leftTeamOverride,
|
|
||||||
leftCountryOverride: scoreboard.value.rightCountryOverride,
|
|
||||||
rightCountryOverride: scoreboard.value.leftCountryOverride,
|
|
||||||
leftCharacter: scoreboard.value.rightCharacter,
|
|
||||||
rightCharacter: scoreboard.value.leftCharacter,
|
|
||||||
leftScore: scoreboard.value.rightScore,
|
|
||||||
rightScore: scoreboard.value.leftScore,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetScores = () => {
|
|
||||||
scoreboard.value = {
|
|
||||||
...scoreboard.value,
|
|
||||||
leftScore: 0,
|
|
||||||
rightScore: 0,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const leftScore = computed({
|
|
||||||
get: () => scoreboard.value.leftScore,
|
|
||||||
set: (value: number) => {
|
|
||||||
scoreboard.value = {
|
|
||||||
...scoreboard.value,
|
|
||||||
leftScore: Math.max(0, Math.floor(value)),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rightScore = computed({
|
|
||||||
get: () => scoreboard.value.rightScore,
|
|
||||||
set: (value: number) => {
|
|
||||||
scoreboard.value = {
|
|
||||||
...scoreboard.value,
|
|
||||||
rightScore: Math.max(0, Math.floor(value)),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
scoreboard,
|
|
||||||
leftScore,
|
|
||||||
rightScore,
|
|
||||||
setScoreboard,
|
|
||||||
swapPlayers,
|
|
||||||
resetScores,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,280 +1,252 @@
|
|||||||
<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 updateRepoOwner = 'Pandipipas';
|
const repoUrl = 'https://github.com/Pandipipas/scoreko-dev';
|
||||||
const updateRepoName = 'scoreko';
|
const authorUrl = 'https://github.com/Pandipipas';
|
||||||
|
|
||||||
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: 'https://github.com/Pandipipas/scoreko-dev'
|
url: authorUrl,
|
||||||
|
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 releaseLabel = computed(() => {
|
const techStack = [
|
||||||
if (!latestRelease.value) {
|
{ label: 'Vue 3', icon: 'hub' },
|
||||||
return '';
|
{ label: 'Quasar', icon: 'style' },
|
||||||
}
|
{ label: 'TypeScript', icon: 'data_object' },
|
||||||
|
{ label: 'NodeCG', icon: 'layers' },
|
||||||
|
];
|
||||||
|
|
||||||
return latestRelease.value.name?.trim().length
|
const currentYear = new Date().getFullYear();
|
||||||
? 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="text-h4 q-mb-md">
|
<div class="q-mb-lg">
|
||||||
{{ t('aboutTitle') }}
|
<div class="text-h5 text-weight-medium">
|
||||||
|
{{ t('aboutTitle') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row q-col-gutter-lg">
|
<QCard
|
||||||
<div class="col-12 col-md-6">
|
flat
|
||||||
<QCard
|
bordered
|
||||||
flat
|
class="about-card"
|
||||||
bordered
|
>
|
||||||
>
|
<!-- App identity -->
|
||||||
<QCardSection class="row items-center q-col-gutter-md">
|
<QCardSection class="q-pa-lg">
|
||||||
<div class="col-auto">
|
<div class="row items-center q-gutter-md">
|
||||||
<QImg
|
<QImg
|
||||||
src="../image.png"
|
src="../image.png"
|
||||||
alt="Scoreko logo"
|
alt="Scoreko logo"
|
||||||
width="72px"
|
class="app-logo"
|
||||||
height="72px"
|
fit="contain"
|
||||||
fit="contain"
|
/>
|
||||||
/>
|
<div>
|
||||||
|
<div class="text-h6 text-weight-bold">
|
||||||
|
{{ appName }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="row items-center q-gutter-xs q-mt-xs">
|
||||||
<div class="text-h6">
|
<QBadge
|
||||||
{{ appName }}
|
outline
|
||||||
</div>
|
|
||||||
<div class="text-caption text-grey-7">
|
|
||||||
{{ t('aboutVersion') }} {{ currentVersion }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</QCardSection>
|
|
||||||
|
|
||||||
<QSeparator />
|
|
||||||
|
|
||||||
<QCardSection>
|
|
||||||
<p class="q-mb-sm">
|
|
||||||
{{ t('aboutDescription') }}
|
|
||||||
</p>
|
|
||||||
<div class="column q-gutter-sm">
|
|
||||||
<QBtn
|
|
||||||
href="https://github.com/nodecg/nodecg"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
icon="open_in_new"
|
|
||||||
:label="t('aboutFrameworkNodeCG')"
|
|
||||||
color="primary"
|
color="primary"
|
||||||
flat
|
class="version-badge"
|
||||||
no-caps
|
>
|
||||||
align="left"
|
v{{ currentVersion }}
|
||||||
/>
|
</QBadge>
|
||||||
</div>
|
<QBtn
|
||||||
</QCardSection>
|
:href="`${repoUrl}/releases`"
|
||||||
|
|
||||||
<QSeparator />
|
|
||||||
|
|
||||||
<QCardSection>
|
|
||||||
<div class="text-subtitle2 q-mb-sm">
|
|
||||||
{{ t('aboutCollaboratorsTitle') }}
|
|
||||||
</div>
|
|
||||||
<QList dense>
|
|
||||||
<QItem
|
|
||||||
v-for="person in collaborators"
|
|
||||||
:key="person.name"
|
|
||||||
tag="a"
|
|
||||||
:href="person.url"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>
|
icon="history"
|
||||||
<QItemSection>
|
:label="t('aboutChangelog')"
|
||||||
<QItemLabel>{{ person.name }}</QItemLabel>
|
color="grey-6"
|
||||||
<QItemLabel caption>
|
flat
|
||||||
{{ person.role }}
|
dense
|
||||||
</QItemLabel>
|
no-caps
|
||||||
</QItemSection>
|
size="xs"
|
||||||
</QItem>
|
/>
|
||||||
</QList>
|
</div>
|
||||||
</QCardSection>
|
</div>
|
||||||
</QCard>
|
</div>
|
||||||
</div>
|
</QCardSection>
|
||||||
|
|
||||||
<div class="col-12 col-md-6">
|
<QSeparator />
|
||||||
<QCard
|
|
||||||
|
<!-- Description + framework -->
|
||||||
|
<QCardSection class="q-pa-lg">
|
||||||
|
<p class="text-body2 text-grey-7 q-mb-md">
|
||||||
|
{{ t('aboutDescription') }}
|
||||||
|
</p>
|
||||||
|
<QBtn
|
||||||
|
href="https://github.com/nodecg/nodecg"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
icon="open_in_new"
|
||||||
|
:label="t('aboutFrameworkNodeCG')"
|
||||||
|
color="primary"
|
||||||
flat
|
flat
|
||||||
bordered
|
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"
|
||||||
>
|
>
|
||||||
<QCardSection>
|
<QItem
|
||||||
<div class="text-h6">
|
v-for="person in collaborators"
|
||||||
{{ t('aboutUpdateSystemTitle') }}
|
:key="person.name"
|
||||||
</div>
|
tag="a"
|
||||||
<div class="text-body2 text-grey-7 q-mt-xs">
|
:href="person.url"
|
||||||
{{ t('aboutUpdateSystemDescription') }}
|
target="_blank"
|
||||||
</div>
|
rel="noopener noreferrer"
|
||||||
</QCardSection>
|
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 }}
|
||||||
|
</QItemLabel>
|
||||||
|
</QItemSection>
|
||||||
|
<QItemSection side>
|
||||||
|
<QIcon
|
||||||
|
name="arrow_forward_ios"
|
||||||
|
size="12px"
|
||||||
|
color="grey-5"
|
||||||
|
/>
|
||||||
|
</QItemSection>
|
||||||
|
</QItem>
|
||||||
|
</QList>
|
||||||
|
</QCardSection>
|
||||||
|
|
||||||
<QSeparator />
|
<QSeparator />
|
||||||
|
|
||||||
<QCardSection class="q-gutter-md">
|
<!-- Footer -->
|
||||||
<QBtn
|
<QCardSection class="q-pa-md">
|
||||||
:label="t('aboutCheckUpdates')"
|
<div class="row items-center justify-between">
|
||||||
color="primary"
|
<span class="text-caption text-grey-5">
|
||||||
icon="sync"
|
© {{ currentYear }} Pandipipas · MIT License
|
||||||
:loading="checkingUpdates"
|
</span>
|
||||||
no-caps
|
<QBtn
|
||||||
@click="checkForUpdates"
|
:href="repoUrl"
|
||||||
/>
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
<QBanner
|
icon="open_in_new"
|
||||||
v-if="latestRelease"
|
label="GitHub"
|
||||||
rounded
|
color="grey-6"
|
||||||
class="bg-grey-2"
|
flat
|
||||||
>
|
dense
|
||||||
<template #avatar>
|
no-caps
|
||||||
<QIcon
|
size="sm"
|
||||||
:name="hasUpdate ? 'system_update_alt' : 'check_circle'"
|
/>
|
||||||
:color="hasUpdate ? 'warning' : 'positive'"
|
</div>
|
||||||
/>
|
</QCardSection>
|
||||||
</template>
|
</QCard>
|
||||||
<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,9 +11,10 @@ useHead({ title: 'Dashboard' });
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<QPage class="q-pa-lg">
|
<QPage class="q-pa-lg">
|
||||||
<div class="dashboard-panels q-mt-lg">
|
<div class="dashboard-panels">
|
||||||
<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"
|
||||||
>
|
>
|
||||||
@@ -25,6 +26,7 @@ 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"
|
||||||
>
|
>
|
||||||
@@ -33,6 +35,7 @@ useHead({ title: 'Dashboard' });
|
|||||||
</QCardSection>
|
</QCardSection>
|
||||||
</QCard>
|
</QCard>
|
||||||
<QCard
|
<QCard
|
||||||
|
flat
|
||||||
bordered
|
bordered
|
||||||
class="dashboard-panel-card"
|
class="dashboard-panel-card"
|
||||||
>
|
>
|
||||||
@@ -53,20 +56,12 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<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 { graphicsSettingsReplicant } from '../../../browser_shared/replicants';
|
import bundlePackage from '../../../../package.json';
|
||||||
|
import { useGraphicsSettingsStore } from '../../stores/graphics-settings';
|
||||||
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;
|
||||||
@@ -25,6 +24,7 @@ type GraphicCard = {
|
|||||||
|
|
||||||
useHead(() => ({ title: t('graphicsTitle') }));
|
useHead(() => ({ title: t('graphicsTitle') }));
|
||||||
|
|
||||||
|
const graphicsSettingsStore = useGraphicsSettingsStore();
|
||||||
const graphics = computed<GraphicConfig[]>(() => bundlePackage.nodecg?.graphics ?? []);
|
const graphics = computed<GraphicConfig[]>(() => bundlePackage.nodecg?.graphics ?? []);
|
||||||
|
|
||||||
const baseUrl = computed(() => {
|
const baseUrl = computed(() => {
|
||||||
@@ -61,7 +61,7 @@ const commentaryGraphic = computed(() =>
|
|||||||
const selectedScoreboardSkin = ref<string>('');
|
const selectedScoreboardSkin = ref<string>('');
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[scoreboardGraphics, () => graphicsSettingsReplicant?.data?.scoreboardSkin],
|
[scoreboardGraphics, () => graphicsSettingsStore.settings.scoreboardSkin],
|
||||||
([availableSkins, replicatedSkin]) => {
|
([availableSkins, replicatedSkin]) => {
|
||||||
if (availableSkins.length === 0) {
|
if (availableSkins.length === 0) {
|
||||||
selectedScoreboardSkin.value = '';
|
selectedScoreboardSkin.value = '';
|
||||||
@@ -88,18 +88,15 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
selectedScoreboardSkin,
|
selectedScoreboardSkin,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (!value || !graphicsSettingsReplicant) {
|
if (!value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (graphicsSettingsReplicant.data?.scoreboardSkin === value) {
|
if (graphicsSettingsStore.settings.scoreboardSkin === value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
graphicsSettingsReplicant.data = {
|
graphicsSettingsStore.setScoreboardSkin(value);
|
||||||
scoreboardSkin: value,
|
|
||||||
};
|
|
||||||
graphicsSettingsReplicant.save();
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
@@ -130,19 +127,29 @@ const cards = computed<GraphicCard[]>(() => {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyUrl = async (graphic: GraphicConfig) => {
|
const copiedCardId = ref<string | null>(null);
|
||||||
|
|
||||||
|
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);
|
||||||
return;
|
} else {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.value = url;
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = document.createElement('input');
|
copiedCardId.value = cardId;
|
||||||
input.value = url;
|
setTimeout(() => {
|
||||||
document.body.appendChild(input);
|
copiedCardId.value = null;
|
||||||
input.select();
|
}, 2000);
|
||||||
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) => {
|
||||||
@@ -165,16 +172,18 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<QPage class="q-pa-lg">
|
<QPage class="q-pa-lg">
|
||||||
<div class="text-h4 q-mb-md">
|
<div class="q-mb-lg">
|
||||||
{{ t('graphicsTitle') }}
|
<div class="text-h5 text-weight-medium">
|
||||||
</div>
|
{{ t('graphicsTitle') }}
|
||||||
<div class="text-body1 q-mb-lg">
|
</div>
|
||||||
{{ t('graphicsDescription') }}
|
<div class="text-body2 text-grey-7 q-mt-xs">
|
||||||
|
{{ t('graphicsDescription') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="cards.length === 0"
|
v-if="cards.length === 0"
|
||||||
class="text-body2 text-grey-5"
|
class="text-body2 text-grey-6"
|
||||||
>
|
>
|
||||||
{{ t('graphicsNoConfigured') }}
|
{{ t('graphicsNoConfigured') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -194,7 +203,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-5">
|
<div class="text-caption text-grey-4">
|
||||||
{{ card.graphic.file }}
|
{{ card.graphic.file }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,18 +233,27 @@ 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="primary"
|
:color="copiedCardId === card.id ? 'positive' : 'primary'"
|
||||||
icon="content_copy"
|
:icon="copiedCardId === card.id ? 'check' : 'content_copy'"
|
||||||
:label="t('graphicsCopyUrl')"
|
no-caps
|
||||||
@click="copyUrl(card.graphic)"
|
:label="copiedCardId === card.id ? t('graphicsCopied') : t('graphicsCopyUrl')"
|
||||||
|
@click="copyUrl(card.graphic, card.id)"
|
||||||
/>
|
/>
|
||||||
<QBtn
|
<QBtn
|
||||||
color="secondary"
|
color="secondary"
|
||||||
icon="open_with"
|
icon="open_with"
|
||||||
:label="t('graphicsDragObs')"
|
no-caps
|
||||||
draggable="true"
|
draggable="true"
|
||||||
|
:label="t('graphicsDragObs')"
|
||||||
@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>
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
<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,
|
||||||
useShortcutSettingsStore,
|
useShortcutSettingsStore,
|
||||||
} from '../stores/shortcut-settings';
|
} from '../../stores/shortcut-settings';
|
||||||
|
|
||||||
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 },
|
||||||
@@ -18,13 +25,14 @@ const languageOptions = computed(() => [
|
|||||||
|
|
||||||
const selectedLanguage = computed<Locale>({
|
const selectedLanguage = computed<Locale>({
|
||||||
get: () => locale.value,
|
get: () => locale.value,
|
||||||
set: (value) => {
|
set: (value) => { setLocale(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') },
|
||||||
@@ -33,6 +41,20 @@ 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') {
|
||||||
@@ -41,132 +63,443 @@ const stopRecording = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onRecordKeydown = (event: KeyboardEvent) => {
|
const onRecordKeydown = (event: KeyboardEvent) => {
|
||||||
if (!recordingAction.value) {
|
if (!recordingAction.value) return;
|
||||||
return;
|
if (event.key === 'Escape') { event.preventDefault(); stopRecording(); return; }
|
||||||
}
|
|
||||||
|
|
||||||
const shortcut = eventToShortcut(event);
|
const shortcut = eventToShortcut(event);
|
||||||
if (!shortcut) {
|
if (!shortcut) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
shortcutSettingsStore.setShortcut(recordingAction.value, shortcut);
|
shortcutSettingsStore.setShortcut(recordingAction.value, shortcut);
|
||||||
stopRecording();
|
stopRecording();
|
||||||
};
|
};
|
||||||
|
|
||||||
const startRecording = (action: ShortcutAction) => {
|
const onDocumentMousedown = (event: MouseEvent) => {
|
||||||
if (recordingAction.value === action) {
|
if (recordingAction.value && shortcutsContainerRef.value && !shortcutsContainerRef.value.contains(event.target as Node)) {
|
||||||
stopRecording();
|
stopRecording();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRecording = (action: ShortcutAction) => {
|
||||||
|
if (recordingAction.value === action) { stopRecording(); return; }
|
||||||
recordingAction.value = action;
|
recordingAction.value = action;
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') document.body.dataset.shortcutRecording = 'true';
|
||||||
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();
|
||||||
});
|
});
|
||||||
|
|
||||||
useHead(() => ({ title: t('settingsTitle') }));
|
// ─── Integraciones ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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="text-h4 q-mb-md">
|
<div class="q-mb-lg">
|
||||||
{{ t('settingsTitle') }}
|
<div class="text-h5 text-weight-medium">
|
||||||
</div>
|
{{ t('settingsTitle') }}
|
||||||
<div class="text-body1 q-mb-lg">
|
</div>
|
||||||
{{ t('settingsDescription') }}
|
<div class="text-body2 text-grey-7 q-mt-xs">
|
||||||
|
{{ t('settingsDescription') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<QCard
|
<div class="column q-gutter-lg settings-layout">
|
||||||
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>
|
|
||||||
|
|
||||||
<QSelect
|
<!-- ── Idioma ──────────────────────────────────────────────────────────── -->
|
||||||
v-model="selectedLanguage"
|
<QCard flat bordered class="settings-card">
|
||||||
emit-value
|
<QCardSection class="q-pa-lg">
|
||||||
map-options
|
<div class="text-overline text-grey-6 q-mb-md">{{ t('settingsLanguageLabel') }}</div>
|
||||||
:label="t('settingsLanguageLabel')"
|
<QSelect
|
||||||
:options="languageOptions"
|
v-model="selectedLanguage"
|
||||||
/>
|
emit-value
|
||||||
|
map-options
|
||||||
<div class="text-caption text-grey-5 q-mt-sm">
|
:options="languageOptions"
|
||||||
{{ t('settingsLanguageHint') }}
|
:label="t('settingsLanguageLabel')"
|
||||||
</div>
|
outlined
|
||||||
</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
|
||||||
flat
|
style="max-width: 280px"
|
||||||
color="primary"
|
/>
|
||||||
icon="restart_alt"
|
<div class="text-caption text-grey-6 q-mt-sm">
|
||||||
:aria-label="t('settingsShortcutReset')"
|
{{ t('settingsLanguageHint') }}
|
||||||
@click="shortcutSettingsStore.resetShortcuts"
|
</div>
|
||||||
|
</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
|
||||||
>
|
>
|
||||||
<QTooltip>{{ t('settingsShortcutReset') }}</QTooltip>
|
<template #avatar>
|
||||||
</QBtn>
|
<QIcon name="warning" color="white" />
|
||||||
</div>
|
</template>
|
||||||
|
{{ t('settingsShortcutConflictWarning') }}
|
||||||
|
</QBanner>
|
||||||
|
|
||||||
<div class="text-caption text-grey-5 q-mb-md">
|
<div ref="shortcutsContainerRef" class="column q-gutter-md">
|
||||||
{{ t('settingsShortcutDescription') }}
|
<QInput
|
||||||
</div>
|
v-for="field in shortcutFields"
|
||||||
|
:key="field.action"
|
||||||
|
:model-value="shortcutSettingsStore.shortcuts[field.action]"
|
||||||
|
:hint="recordingAction === field.action ? t('settingsShortcutRecordingHint') : field.hint"
|
||||||
|
:color="
|
||||||
|
recordingAction === field.action
|
||||||
|
? 'negative'
|
||||||
|
: conflictingActions.has(field.action)
|
||||||
|
? 'warning'
|
||||||
|
: 'primary'
|
||||||
|
"
|
||||||
|
readonly outlined dense bottom-slots
|
||||||
|
:label="field.label"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<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 class="column q-gutter-md">
|
</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
|
<QInput
|
||||||
v-for="field in shortcutFields"
|
v-model="startggManualDraft"
|
||||||
:key="field.action"
|
label="Paste your personal token"
|
||||||
:model-value="shortcutSettingsStore.shortcuts[field.action]"
|
dense outlined type="password"
|
||||||
readonly
|
/>
|
||||||
:label="field.label"
|
</QCardSection>
|
||||||
>
|
<QSeparator />
|
||||||
<template #append>
|
<QCardActions align="right">
|
||||||
<QBtn
|
<QBtn flat no-caps label="Cancel" color="secondary" @click="isStartggManualDialogOpen = false" />
|
||||||
flat
|
<QBtn flat no-caps color="negative" label="Delete token" @click="startggManualDraft = ''; saveStartggManualToken()" />
|
||||||
round
|
<QBtn no-caps color="primary" label="Save token" @click="saveStartggManualToken" />
|
||||||
dense
|
</QCardActions>
|
||||||
:icon="recordingAction === field.action ? 'stop_circle' : 'keyboard'"
|
</QCard>
|
||||||
:color="recordingAction === field.action ? 'negative' : 'primary'"
|
</QDialog>
|
||||||
@click="startRecording(field.action)"
|
|
||||||
/>
|
<!-- ── Diálogo token personal Challonge ─────────────────────────────────── -->
|
||||||
</template>
|
<QDialog v-model="isChallongeManualDialogOpen">
|
||||||
<template #hint>
|
<QCard class="settings-dialog">
|
||||||
{{ recordingAction === field.action ? t('settingsShortcutRecordingHint') : field.hint }}
|
<QCardSection>
|
||||||
</template>
|
<div class="text-h6">Personal Challonge API token</div>
|
||||||
</QInput>
|
</QCardSection>
|
||||||
</div>
|
<QSeparator />
|
||||||
</QCardSection>
|
<QCardSection>
|
||||||
</QCard>
|
<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 {
|
||||||
max-width: 720px;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { sendNodecgMessage } from '../../nodecg/browser/messages';
|
||||||
|
|
||||||
|
export const sendIntegrationMessage = <T>(
|
||||||
|
messagePrefix: string,
|
||||||
|
action: string,
|
||||||
|
payload: unknown,
|
||||||
|
): Promise<T> =>
|
||||||
|
sendNodecgMessage<T>(`${messagePrefix}:${action}`, payload);
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { sendNodecgCommand, sendNodecgMessage } from '../../nodecg/browser/messages';
|
||||||
|
import { createPackBrowserReplicants } from '../../nodecg/browser/packReplicants';
|
||||||
|
import { messageNames } from '../../nodecg/messageNames';
|
||||||
|
import type {
|
||||||
|
PackDownloadState,
|
||||||
|
PackManifest,
|
||||||
|
PackRegistry,
|
||||||
|
PackUpdateInfo,
|
||||||
|
} from '../../shared/domain/packs/types';
|
||||||
|
|
||||||
|
export interface PackReplicantHandlers {
|
||||||
|
onRegistryChanged: (value: PackRegistry | null) => void;
|
||||||
|
onInstalledPacksChanged: (value: string[], previousValue: string[]) => void;
|
||||||
|
onDownloadStatesChanged: (value: Record<string, PackDownloadState>) => void;
|
||||||
|
onAvailableUpdatesChanged: (value: Record<string, PackUpdateInfo>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackService {
|
||||||
|
subscribe: (handlers: PackReplicantHandlers) => Promise<() => void>;
|
||||||
|
fetchRegistry: () => Promise<void>;
|
||||||
|
downloadPack: (packId: string) => Promise<void>;
|
||||||
|
uninstallPack: (packId: string) => Promise<void>;
|
||||||
|
updatePack: (packId: string) => Promise<void>;
|
||||||
|
readLocalManifest: (packId: string) => Promise<PackManifest>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPackService = (): PackService => {
|
||||||
|
const subscribe = async (handlers: PackReplicantHandlers): Promise<() => void> => {
|
||||||
|
const {
|
||||||
|
registryRep,
|
||||||
|
installedRep,
|
||||||
|
statesRep,
|
||||||
|
updatesRep,
|
||||||
|
waitUntilReady,
|
||||||
|
} = createPackBrowserReplicants();
|
||||||
|
|
||||||
|
await waitUntilReady();
|
||||||
|
|
||||||
|
handlers.onRegistryChanged(registryRep.value ?? null);
|
||||||
|
handlers.onInstalledPacksChanged(installedRep.value ?? [], []);
|
||||||
|
handlers.onDownloadStatesChanged(statesRep.value ?? {});
|
||||||
|
handlers.onAvailableUpdatesChanged(updatesRep.value ?? {});
|
||||||
|
|
||||||
|
const onRegistryChanged = (value: PackRegistry | null): void => {
|
||||||
|
handlers.onRegistryChanged(value ?? null);
|
||||||
|
};
|
||||||
|
const onInstalledPacksChanged = (value: string[], previousValue?: string[]): void => {
|
||||||
|
handlers.onInstalledPacksChanged(value ?? [], previousValue ?? []);
|
||||||
|
};
|
||||||
|
const onDownloadStatesChanged = (value: Record<string, PackDownloadState>): void => {
|
||||||
|
handlers.onDownloadStatesChanged(value ?? {});
|
||||||
|
};
|
||||||
|
const onAvailableUpdatesChanged = (value: Record<string, PackUpdateInfo>): void => {
|
||||||
|
handlers.onAvailableUpdatesChanged(value ?? {});
|
||||||
|
};
|
||||||
|
|
||||||
|
registryRep.on('change', onRegistryChanged);
|
||||||
|
installedRep.on('change', onInstalledPacksChanged);
|
||||||
|
statesRep.on('change', onDownloadStatesChanged);
|
||||||
|
updatesRep.on('change', onAvailableUpdatesChanged);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
registryRep.off('change', onRegistryChanged);
|
||||||
|
installedRep.off('change', onInstalledPacksChanged);
|
||||||
|
statesRep.off('change', onDownloadStatesChanged);
|
||||||
|
updatesRep.off('change', onAvailableUpdatesChanged);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
fetchRegistry: () => sendNodecgCommand(messageNames.packs.fetchRegistry),
|
||||||
|
downloadPack: (packId: string) => sendNodecgCommand(messageNames.packs.download, packId),
|
||||||
|
uninstallPack: (packId: string) => sendNodecgCommand(messageNames.packs.uninstall, packId),
|
||||||
|
updatePack: (packId: string) => sendNodecgCommand(messageNames.packs.update, packId),
|
||||||
|
readLocalManifest: (packId: string) =>
|
||||||
|
sendNodecgMessage<PackManifest>(messageNames.packs.readLocalManifest, packId),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { ref, watch, type Ref } from 'vue';
|
import { ref, watch, type Ref } from 'vue';
|
||||||
|
import { commentaryReplicant, graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../nodecg/browser/replicants';
|
||||||
|
import { normalizeCommentary } from '../../shared/domain/commentary';
|
||||||
|
import { normalizeGraphicsSettings } from '../../shared/domain/graphics';
|
||||||
|
import { normalizePlayers } from '../../shared/domain/players/state';
|
||||||
|
import { normalizeScoreboard } from '../../shared/domain/scoreboard';
|
||||||
|
import type { Schemas } from '../../types';
|
||||||
|
|
||||||
interface ReplicantLike<T> {
|
interface ReplicantLike<T> {
|
||||||
data: T | undefined;
|
data: T | undefined;
|
||||||
@@ -36,7 +42,7 @@ export const writeStorageSnapshot = <T>(storageKey: string, value: T): void => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const syncStateWithReplicant = <T>(
|
const syncStateWithReplicant = <T>(
|
||||||
state: Ref<T>,
|
state: Ref<T>,
|
||||||
replicant: ReplicantLike<T> | undefined,
|
replicant: ReplicantLike<T> | undefined,
|
||||||
normalize: (input: unknown) => T,
|
normalize: (input: unknown) => T,
|
||||||
@@ -44,24 +50,21 @@ export const syncStateWithReplicant = <T>(
|
|||||||
): void => {
|
): void => {
|
||||||
const isApplyingReplicant = ref(false);
|
const isApplyingReplicant = ref(false);
|
||||||
const persistSnapshot = (value: T): void => {
|
const persistSnapshot = (value: T): void => {
|
||||||
if (!storageKey) {
|
if (storageKey) {
|
||||||
return;
|
writeStorageSnapshot(storageKey, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
writeStorageSnapshot(storageKey, value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => replicant?.data,
|
() => replicant?.data,
|
||||||
(value) => {
|
(value) => {
|
||||||
if (!value) {
|
if (value === undefined) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isApplyingReplicant.value = true;
|
isApplyingReplicant.value = true;
|
||||||
state.value = normalize(value);
|
state.value = normalize(value);
|
||||||
isApplyingReplicant.value = false;
|
isApplyingReplicant.value = false;
|
||||||
|
|
||||||
persistSnapshot(state.value);
|
persistSnapshot(state.value);
|
||||||
},
|
},
|
||||||
{ deep: true, immediate: true },
|
{ deep: true, immediate: true },
|
||||||
@@ -70,16 +73,32 @@ export const syncStateWithReplicant = <T>(
|
|||||||
watch(
|
watch(
|
||||||
state,
|
state,
|
||||||
(value) => {
|
(value) => {
|
||||||
persistSnapshot(value);
|
const normalized = normalize(value);
|
||||||
|
persistSnapshot(normalized);
|
||||||
|
|
||||||
if (isApplyingReplicant.value || !replicant) {
|
if (isApplyingReplicant.value || !replicant) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replicants remain the source of truth for server/browser synchronization.
|
replicant.data = normalized;
|
||||||
replicant.data = normalize(value);
|
|
||||||
replicant.save();
|
replicant.save();
|
||||||
},
|
},
|
||||||
{ deep: true, flush: 'sync' },
|
{ deep: true, flush: 'sync' },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const syncScoreboardState = (state: Ref<Schemas.Scoreboard>, storageKey: string): void => {
|
||||||
|
syncStateWithReplicant(state, scoreboardReplicant, normalizeScoreboard, storageKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncPlayersState = (state: Ref<Schemas.Players>, storageKey: string): void => {
|
||||||
|
syncStateWithReplicant(state, playersReplicant, normalizePlayers, storageKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncCommentaryState = (state: Ref<Schemas.Commentary>): void => {
|
||||||
|
syncStateWithReplicant(state, commentaryReplicant, normalizeCommentary);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncGraphicsSettingsState = (state: Ref<Schemas.GraphicsSettings>): void => {
|
||||||
|
syncStateWithReplicant(state, graphicsSettingsReplicant, normalizeGraphicsSettings);
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
defaultCommentary,
|
||||||
|
normalizeCommentary,
|
||||||
|
swapCommentary,
|
||||||
|
type Commentary,
|
||||||
|
} from '../../shared/domain/commentary';
|
||||||
|
import { syncCommentaryState } from '../services/replicant-state-service';
|
||||||
|
|
||||||
|
export const useCommentaryStore = defineStore('commentary', () => {
|
||||||
|
const commentary = ref<Commentary>({ ...defaultCommentary });
|
||||||
|
syncCommentaryState(commentary);
|
||||||
|
|
||||||
|
const setCommentary = (value: Commentary): void => {
|
||||||
|
commentary.value = normalizeCommentary(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCommentary = (): void => {
|
||||||
|
commentary.value = { ...defaultCommentary };
|
||||||
|
};
|
||||||
|
|
||||||
|
const leftCommentator = computed({
|
||||||
|
get: () => commentary.value.leftCommentator,
|
||||||
|
set: (value: string) => {
|
||||||
|
commentary.value = {
|
||||||
|
...commentary.value,
|
||||||
|
leftCommentator: value,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const leftCommentatorTwitter = computed({
|
||||||
|
get: () => commentary.value.leftCommentatorTwitter,
|
||||||
|
set: (value: string) => {
|
||||||
|
commentary.value = {
|
||||||
|
...commentary.value,
|
||||||
|
leftCommentatorTwitter: value,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightCommentator = computed({
|
||||||
|
get: () => commentary.value.rightCommentator,
|
||||||
|
set: (value: string) => {
|
||||||
|
commentary.value = {
|
||||||
|
...commentary.value,
|
||||||
|
rightCommentator: value,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightCommentatorTwitter = computed({
|
||||||
|
get: () => commentary.value.rightCommentatorTwitter,
|
||||||
|
set: (value: string) => {
|
||||||
|
commentary.value = {
|
||||||
|
...commentary.value,
|
||||||
|
rightCommentatorTwitter: value,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const swapCommentators = (): void => {
|
||||||
|
commentary.value = swapCommentary(commentary.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
commentary,
|
||||||
|
leftCommentator,
|
||||||
|
leftCommentatorTwitter,
|
||||||
|
rightCommentator,
|
||||||
|
rightCommentatorTwitter,
|
||||||
|
setCommentary,
|
||||||
|
clearCommentary,
|
||||||
|
swapCommentators,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import {
|
||||||
|
defaultGraphicsSettings,
|
||||||
|
normalizeGraphicsSettings,
|
||||||
|
type GraphicsSettings,
|
||||||
|
} from '../../shared/domain/graphics';
|
||||||
|
import { syncGraphicsSettingsState } from '../services/replicant-state-service';
|
||||||
|
|
||||||
|
export const useGraphicsSettingsStore = defineStore('graphics-settings', () => {
|
||||||
|
const settings = ref<GraphicsSettings>({ ...defaultGraphicsSettings });
|
||||||
|
|
||||||
|
syncGraphicsSettingsState(settings);
|
||||||
|
|
||||||
|
const setSettings = (value: GraphicsSettings): void => {
|
||||||
|
settings.value = normalizeGraphicsSettings(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setScoreboardSkin = (scoreboardSkin: string): void => {
|
||||||
|
settings.value = normalizeGraphicsSettings({
|
||||||
|
...settings.value,
|
||||||
|
scoreboardSkin,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings,
|
||||||
|
setSettings,
|
||||||
|
setScoreboardSkin,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { createPackService } from '../services/pack-service';
|
||||||
|
import {
|
||||||
|
buildCharactersByGame,
|
||||||
|
buildDefaultCharactersByGame,
|
||||||
|
type DefaultCharacterPair,
|
||||||
|
type FightingCharacterOption,
|
||||||
|
} from '../../shared/domain/packs/characters';
|
||||||
|
import type {
|
||||||
|
GameSelectOption,
|
||||||
|
PackDownloadState,
|
||||||
|
PackManifest,
|
||||||
|
PackRegistry,
|
||||||
|
PackUpdateInfo,
|
||||||
|
} from '../../shared/domain/packs/types';
|
||||||
|
|
||||||
|
const packService = createPackService();
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) {
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
if (bytes < 1024 * 1024) {
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLocalLogoUrl = (packId: string): string => `/packs/${packId}/logo.png`;
|
||||||
|
|
||||||
|
export const usePacksStore = defineStore('packs', () => {
|
||||||
|
const initialized = ref(false);
|
||||||
|
const registry = ref<PackRegistry | null>(null);
|
||||||
|
const installedPackIds = ref<string[]>([]);
|
||||||
|
const downloadStates = ref<Record<string, PackDownloadState>>({});
|
||||||
|
const availableUpdates = ref<Record<string, PackUpdateInfo>>({});
|
||||||
|
const installedManifests = ref<Record<string, PackManifest>>({});
|
||||||
|
const loadingManifestIds = new Set<string>();
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
let registryRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const installedManifestList = computed(() =>
|
||||||
|
installedPackIds.value
|
||||||
|
.map((packId) => installedManifests.value[packId])
|
||||||
|
.filter((manifest): manifest is PackManifest => Boolean(manifest)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const charactersByGame = computed(() => buildCharactersByGame(installedManifestList.value));
|
||||||
|
const defaultCharactersByGame = computed(() => buildDefaultCharactersByGame(installedManifestList.value));
|
||||||
|
|
||||||
|
const allGameOptions = computed<GameSelectOption[]>(() => {
|
||||||
|
if (!registry.value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return registry.value.packs.map((entry) => ({
|
||||||
|
label: entry.name,
|
||||||
|
value: entry.name,
|
||||||
|
available: installedPackIds.value.includes(entry.id),
|
||||||
|
registryEntry: entry,
|
||||||
|
updateInfo: availableUpdates.value[entry.id],
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCount = computed(() => Object.keys(availableUpdates.value).length);
|
||||||
|
|
||||||
|
const loadInstalledManifest = (packId: string): void => {
|
||||||
|
if (installedManifests.value[packId] || loadingManifestIds.has(packId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingManifestIds.add(packId);
|
||||||
|
packService.readLocalManifest(packId)
|
||||||
|
.then((manifest) => {
|
||||||
|
installedManifests.value = {
|
||||||
|
...installedManifests.value,
|
||||||
|
[packId]: manifest,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error(`[packs] Failed to load manifest for "${packId}":`, error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loadingManifestIds.delete(packId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncInstalledManifests = (nextPackIds: string[]): void => {
|
||||||
|
const nextSet = new Set(nextPackIds);
|
||||||
|
const nextManifests: Record<string, PackManifest> = {};
|
||||||
|
|
||||||
|
Object.entries(installedManifests.value).forEach(([packId, manifest]) => {
|
||||||
|
if (nextSet.has(packId)) {
|
||||||
|
nextManifests[packId] = manifest;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
installedManifests.value = nextManifests;
|
||||||
|
|
||||||
|
nextPackIds.forEach(loadInstalledManifest);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialize = (): void => {
|
||||||
|
if (initialized.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initialized.value = true;
|
||||||
|
packService.subscribe({
|
||||||
|
onRegistryChanged: (value) => {
|
||||||
|
registry.value = value;
|
||||||
|
},
|
||||||
|
onInstalledPacksChanged: (value) => {
|
||||||
|
installedPackIds.value = [...value];
|
||||||
|
syncInstalledManifests(value);
|
||||||
|
},
|
||||||
|
onDownloadStatesChanged: (value) => {
|
||||||
|
downloadStates.value = { ...value };
|
||||||
|
},
|
||||||
|
onAvailableUpdatesChanged: (value) => {
|
||||||
|
availableUpdates.value = { ...value };
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((dispose) => {
|
||||||
|
unsubscribe = dispose;
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
initialized.value = false;
|
||||||
|
console.error('[packs] Failed to subscribe to pack replicants:', error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispose = (): void => {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
if (registryRefreshTimer) {
|
||||||
|
clearInterval(registryRefreshTimer);
|
||||||
|
registryRefreshTimer = null;
|
||||||
|
}
|
||||||
|
initialized.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCommand = (label: string, command: () => Promise<void>): void => {
|
||||||
|
command().catch((error: unknown) => {
|
||||||
|
console.error(`[packs] ${label} failed:`, error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRegistry = (): void => {
|
||||||
|
runCommand('fetchRegistry', packService.fetchRegistry);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startRegistryRefresh = (intervalMs = 15_000): void => {
|
||||||
|
fetchRegistry();
|
||||||
|
if (registryRefreshTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
registryRefreshTimer = setInterval(fetchRegistry, intervalMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRegistryRefresh = (): void => {
|
||||||
|
if (!registryRefreshTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInterval(registryRefreshTimer);
|
||||||
|
registryRefreshTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadPack = (packId: string): void => {
|
||||||
|
runCommand(`downloadPack "${packId}"`, () => packService.downloadPack(packId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const uninstallPack = (packId: string): void => {
|
||||||
|
runCommand(`uninstallPack "${packId}"`, () => packService.uninstallPack(packId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePack = (packId: string): void => {
|
||||||
|
runCommand(`updatePack "${packId}"`, () => packService.updatePack(packId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isGameAvailable = (gameName: string): boolean => {
|
||||||
|
const entry = registry.value?.packs.find((pack) => pack.name === gameName);
|
||||||
|
return entry ? installedPackIds.value.includes(entry.id) : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDownloadState = (packId: string): PackDownloadState =>
|
||||||
|
downloadStates.value[packId] ?? { status: 'idle', progress: 0 };
|
||||||
|
|
||||||
|
const getCharactersByGame = (gameName: string): FightingCharacterOption[] =>
|
||||||
|
charactersByGame.value[gameName] ?? [];
|
||||||
|
|
||||||
|
const getDefaultCharactersByGame = (gameName: string): DefaultCharacterPair | undefined =>
|
||||||
|
defaultCharactersByGame.value[gameName];
|
||||||
|
|
||||||
|
return {
|
||||||
|
registry,
|
||||||
|
installedPackIds,
|
||||||
|
downloadStates,
|
||||||
|
availableUpdates,
|
||||||
|
installedManifests,
|
||||||
|
allGameOptions,
|
||||||
|
updateCount,
|
||||||
|
initialize,
|
||||||
|
dispose,
|
||||||
|
fetchRegistry,
|
||||||
|
startRegistryRefresh,
|
||||||
|
stopRegistryRefresh,
|
||||||
|
downloadPack,
|
||||||
|
uninstallPack,
|
||||||
|
updatePack,
|
||||||
|
isGameAvailable,
|
||||||
|
getDownloadState,
|
||||||
|
getCharactersByGame,
|
||||||
|
getDefaultCharactersByGame,
|
||||||
|
formatBytes,
|
||||||
|
getLocalLogoUrl,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
normalizePlayer,
|
||||||
|
normalizePlayers,
|
||||||
|
type Player,
|
||||||
|
type PlayersMap,
|
||||||
|
} from '../../shared/domain/players/state';
|
||||||
|
import { readStorageSnapshot, syncPlayersState } from '../services/replicant-state-service';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'scoreko-dev.players';
|
||||||
|
|
||||||
|
export const usePlayersStore = defineStore('players', () => {
|
||||||
|
const players = ref<PlayersMap>({});
|
||||||
|
const storageSnapshot = readStorageSnapshot(STORAGE_KEY, normalizePlayers);
|
||||||
|
if (storageSnapshot) {
|
||||||
|
players.value = storageSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPlayersState(players, STORAGE_KEY);
|
||||||
|
|
||||||
|
const setPlayers = (value: PlayersMap): void => {
|
||||||
|
players.value = normalizePlayers(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertPlayer = (id: string, player: Player): void => {
|
||||||
|
players.value = {
|
||||||
|
...players.value,
|
||||||
|
[id]: normalizePlayer(player),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePlayer = (id: string): void => {
|
||||||
|
const next = { ...players.value };
|
||||||
|
delete next[id];
|
||||||
|
players.value = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rows = computed(() => Object.entries(players.value).map(([id, player]) => ({
|
||||||
|
id,
|
||||||
|
...player,
|
||||||
|
})));
|
||||||
|
|
||||||
|
return {
|
||||||
|
players,
|
||||||
|
rows,
|
||||||
|
setPlayers,
|
||||||
|
upsertPlayer,
|
||||||
|
removePlayer,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
adjustScoreboardScore,
|
||||||
|
defaultScoreboard,
|
||||||
|
normalizeScoreboard,
|
||||||
|
resetScoreboardScores,
|
||||||
|
setScoreboardScore,
|
||||||
|
swapScoreboardPlayers,
|
||||||
|
type Scoreboard,
|
||||||
|
type ScoreboardSide,
|
||||||
|
} from '../../shared/domain/scoreboard';
|
||||||
|
import { readStorageSnapshot, syncScoreboardState } from '../services/replicant-state-service';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'scoreko-dev.scoreboard';
|
||||||
|
|
||||||
|
export const useScoreboardStore = defineStore('scoreboard', () => {
|
||||||
|
const scoreboard = ref<Scoreboard>({ ...defaultScoreboard });
|
||||||
|
const storageSnapshot = readStorageSnapshot(STORAGE_KEY, normalizeScoreboard);
|
||||||
|
if (storageSnapshot) {
|
||||||
|
scoreboard.value = storageSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncScoreboardState(scoreboard, STORAGE_KEY);
|
||||||
|
|
||||||
|
const setScoreboard = (value: Scoreboard): void => {
|
||||||
|
scoreboard.value = normalizeScoreboard(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setGame = (value: string): void => {
|
||||||
|
scoreboard.value = { ...scoreboard.value, game: value };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRound = (value: string): void => {
|
||||||
|
scoreboard.value = { ...scoreboard.value, round: value };
|
||||||
|
};
|
||||||
|
|
||||||
|
const setScore = (side: ScoreboardSide, value: number): void => {
|
||||||
|
scoreboard.value = setScoreboardScore(scoreboard.value, side, value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const adjustScore = (side: ScoreboardSide, delta: number): void => {
|
||||||
|
scoreboard.value = adjustScoreboardScore(scoreboard.value, side, delta);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSidePlayerId = (side: ScoreboardSide, value: string): void => {
|
||||||
|
scoreboard.value = {
|
||||||
|
...scoreboard.value,
|
||||||
|
[side === 'left' ? 'leftPlayerId' : 'rightPlayerId']: value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSideNameOverride = (side: ScoreboardSide, value: string): void => {
|
||||||
|
scoreboard.value = {
|
||||||
|
...scoreboard.value,
|
||||||
|
[side === 'left' ? 'leftNameOverride' : 'rightNameOverride']: value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSideTeamOverride = (side: ScoreboardSide, value: string): void => {
|
||||||
|
scoreboard.value = {
|
||||||
|
...scoreboard.value,
|
||||||
|
[side === 'left' ? 'leftTeamOverride' : 'rightTeamOverride']: value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSideCountryOverride = (side: ScoreboardSide, value: string): void => {
|
||||||
|
scoreboard.value = {
|
||||||
|
...scoreboard.value,
|
||||||
|
[side === 'left' ? 'leftCountryOverride' : 'rightCountryOverride']: value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSideCharacter = (side: ScoreboardSide, value: string): void => {
|
||||||
|
scoreboard.value = {
|
||||||
|
...scoreboard.value,
|
||||||
|
[side === 'left' ? 'leftCharacter' : 'rightCharacter']: value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const swapPlayers = (): void => {
|
||||||
|
scoreboard.value = swapScoreboardPlayers(scoreboard.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetScores = (): void => {
|
||||||
|
scoreboard.value = resetScoreboardScores(scoreboard.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const leftScore = computed({
|
||||||
|
get: () => scoreboard.value.leftScore,
|
||||||
|
set: (value: number) => {
|
||||||
|
setScore('left', value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rightScore = computed({
|
||||||
|
get: () => scoreboard.value.rightScore,
|
||||||
|
set: (value: number) => {
|
||||||
|
setScore('right', value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
scoreboard,
|
||||||
|
leftScore,
|
||||||
|
rightScore,
|
||||||
|
setScoreboard,
|
||||||
|
setGame,
|
||||||
|
setRound,
|
||||||
|
setScore,
|
||||||
|
adjustScore,
|
||||||
|
setSidePlayerId,
|
||||||
|
setSideNameOverride,
|
||||||
|
setSideTeamOverride,
|
||||||
|
setSideCountryOverride,
|
||||||
|
setSideCharacter,
|
||||||
|
swapPlayers,
|
||||||
|
resetScores,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -175,9 +175,15 @@ 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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { createServer, type Server, type ServerResponse } from 'node:http';
|
import { nodecg } from '../nodecg/extension/context.js';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { listenForMessage } from '../nodecg/extension/messages.js';
|
||||||
import { nodecg } from './util/nodecg.js';
|
import { messageNames } from '../nodecg/messageNames.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';
|
||||||
@@ -17,21 +20,17 @@ 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;
|
||||||
|
|
||||||
interface OAuthConfig {
|
// ─── URL del proxy OAuth ───────────────────────────────────────────────────────
|
||||||
clientId: string;
|
// Rellena esta constante con la URL de tu Cloudflare Worker tras el deploy.
|
||||||
clientSecret: string;
|
// Formato: 'https://scoreko-oauth-proxy.TU-SUBDOMINIO.workers.dev'
|
||||||
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';
|
||||||
|
|
||||||
interface OAuthSession {
|
// ─── Tipos ─────────────────────────────────────────────────────────────────────
|
||||||
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;
|
||||||
@@ -57,157 +56,155 @@ interface ImportedPlayer {
|
|||||||
twitter: string;
|
twitter: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oauthSessions = new Map<string, OAuthSession>();
|
// ─── Modo OAuth ────────────────────────────────────────────────────────────────
|
||||||
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.
|
||||||
|
|
||||||
const getStringProp = (payload: unknown, key: string): string => {
|
type OAuthMode =
|
||||||
if (typeof payload !== 'object' || payload === null || !(key in payload)) {
|
| { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
|
||||||
return '';
|
| { type: 'proxy'; proxyBaseUrl: string; callbackPort: number };
|
||||||
|
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = (payload as Record<string, unknown>)[key];
|
nodecg.log.info(`[Challonge] OAuth: modo proxy → ${proxyBaseUrl}`);
|
||||||
return typeof value === 'string' ? value.trim() : String(value || '').trim();
|
return { type: 'proxy', proxyBaseUrl, callbackPort };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNumberProp = (payload: Record<string, unknown>, keys: string[]): number | null => {
|
// ─── Exchange de token ─────────────────────────────────────────────────────────
|
||||||
for (const key of keys) {
|
|
||||||
const raw = payload[key];
|
|
||||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
const parsed = Number(raw);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
/** Modo dev: exchange directo con Challonge usando credenciales locales */
|
||||||
if (typeof ack !== 'function') {
|
const exchangeCodeDirectly = async (
|
||||||
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,
|
||||||
oauthConfig: OAuthConfig,
|
clientId: string,
|
||||||
|
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: oauthConfig.clientId,
|
client_id: clientId,
|
||||||
client_secret: oauthConfig.clientSecret,
|
client_secret: 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: {
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: params.toString(),
|
body: params.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = await parseOAuthTokenPayload(response);
|
const rawBody = await response.text();
|
||||||
|
let payload: OAuthTokenResponse;
|
||||||
if (!response.ok) {
|
try {
|
||||||
throw new Error(payload.error_description || payload.error || payload.message || `OAuth token request failed (${response.status})`);
|
payload = JSON.parse(rawBody) as OAuthTokenResponse;
|
||||||
|
} catch {
|
||||||
|
payload = { message: rawBody };
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = String(payload.access_token || '').trim();
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
payload.error_description ??
|
||||||
|
payload.error ??
|
||||||
|
payload.message ??
|
||||||
|
`OAuth token request failed (${response.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = String(payload.access_token ?? '').trim();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error(payload.error_description || payload.error || payload.message || 'OAuth token response did not include an access token');
|
throw new Error(
|
||||||
|
payload.error_description ??
|
||||||
|
payload.error ??
|
||||||
|
payload.message ??
|
||||||
|
'OAuth token response did not include an access token',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseJsonResponse = async (response: Response): Promise<unknown> => {
|
/** 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();
|
const rawBody = await response.text();
|
||||||
if (!rawBody) {
|
let payload: { access_token?: string; error?: string };
|
||||||
return null;
|
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> => {
|
||||||
|
void _config;
|
||||||
|
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 rawBody = await response.text();
|
||||||
|
if (!rawBody) return null;
|
||||||
try {
|
try {
|
||||||
return JSON.parse(rawBody) as unknown;
|
return JSON.parse(rawBody) as unknown;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -218,6 +215,7 @@ 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',
|
||||||
@@ -226,14 +224,13 @@ 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 for personal API keys pasted manually (v1 auth style).
|
// ── Fallback v1 (API key personal pegada manualmente) ─────────────────────
|
||||||
if (v2Response.status === 401) {
|
if (v2Response.status === 401) {
|
||||||
const v1Response = await fetch(requestUrl, {
|
const v1Response = await fetch(requestUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -243,46 +240,68 @@ 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 maybeError = v2Payload as { errors?: { detail?: string }; error?: string } | null;
|
const v1Error = v1Payload as ChallongeErrorPayload;
|
||||||
if (!v2Response.ok) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
maybeError?.errors?.detail || maybeError?.error || `Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(),
|
v1Error?.errors?.detail ??
|
||||||
|
v1Error?.error ??
|
||||||
|
`Challonge responded with ${v1Response.status} ${v1Response.statusText}`.trim(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return v2Payload;
|
// ── Otros errores v2 (4xx/5xx que no sean 401) ────────────────────────────
|
||||||
|
const v2Error = v2Payload as ChallongeErrorPayload;
|
||||||
|
throw new Error(
|
||||||
|
v2Error?.errors?.detail ??
|
||||||
|
v2Error?.error ??
|
||||||
|
`Challonge responded with ${v2Response.status} ${v2Response.statusText}`.trim(),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Parsers de respuesta ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const normalizeTournamentSlug = (value: string): string => {
|
const normalizeTournamentSlug = (value: string): string => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) return '';
|
||||||
return '';
|
return trimmed
|
||||||
|
.replace(/^https?:\/\/[^/]+\//i, '')
|
||||||
|
.replace(/^tournaments\//i, '')
|
||||||
|
.replace(/^\/+/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNumberProp = (payload: Record<string, unknown>, keys: string[]): number | null => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const raw = payload[key];
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw)) return raw;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return trimmed.replace(/^https?:\/\/[^/]+\//i, '').replace(/^tournaments\//i, '').replace(/^\/+/, '');
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
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 = (typeof candidate.attributes === 'object' && candidate.attributes !== null)
|
const attributes =
|
||||||
? (candidate.attributes as Record<string, unknown>)
|
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
||||||
: candidate;
|
? (candidate.attributes as Record<string, unknown>)
|
||||||
|
: 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(String(attributes.url || attributes.slug || attributes.identifier || id));
|
const slug = normalizeTournamentSlug(
|
||||||
|
String(attributes.url ?? attributes.slug ?? attributes.identifier ?? id),
|
||||||
|
);
|
||||||
|
|
||||||
if (!id || !name || !slug) {
|
if (!id || !name || !slug) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
id,
|
id,
|
||||||
@@ -294,26 +313,25 @@ const parseRecentTournaments = (payload: unknown): RecentTournament[] => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (Array.isArray(payload)) {
|
if (Array.isArray(payload)) {
|
||||||
payload.forEach((row) => {
|
for (const row of payload) {
|
||||||
const wrapper = row as Record<string, unknown>;
|
const wrapper = row as Record<string, unknown>;
|
||||||
const tournament = (typeof wrapper.tournament === 'object' && wrapper.tournament !== null)
|
const tournament =
|
||||||
? (wrapper.tournament as Record<string, unknown>)
|
typeof wrapper.tournament === 'object' && wrapper.tournament !== null
|
||||||
: wrapper;
|
? (wrapper.tournament as Record<string, unknown>)
|
||||||
|
: wrapper;
|
||||||
push(tournament);
|
push(tournament);
|
||||||
});
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof payload === 'object' && payload !== null) {
|
if (typeof payload === 'object' && payload !== null) {
|
||||||
const root = payload as Record<string, unknown>;
|
const data = (payload as Record<string, unknown>).data;
|
||||||
const data = root.data;
|
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
data.forEach((row) => {
|
for (const row of data) {
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,201 +342,137 @@ 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 = (typeof candidate.attributes === 'object' && candidate.attributes !== null)
|
const attributes =
|
||||||
? (candidate.attributes as Record<string, unknown>)
|
typeof candidate.attributes === 'object' && candidate.attributes !== null
|
||||||
: candidate;
|
? (candidate.attributes as Record<string, unknown>)
|
||||||
|
: candidate;
|
||||||
|
|
||||||
const id = String(candidate.id || attributes.id || attributes.participant_id || '').trim();
|
const id = String(
|
||||||
const gamertag = String(
|
candidate.id ?? attributes.id ?? attributes.participant_id ?? '',
|
||||||
attributes.display_name
|
|
||||||
|| attributes.name
|
|
||||||
|| attributes.username
|
|
||||||
|| attributes.gamer_tag
|
|
||||||
|| '',
|
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
if (!id || !gamertag) {
|
const rawDisplayName = String(
|
||||||
return;
|
attributes.display_name ??
|
||||||
}
|
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: gamertag,
|
name: '',
|
||||||
team: String(attributes.group_player_ids || attributes.team_name || '').trim(),
|
team,
|
||||||
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)) {
|
||||||
payload.forEach((row) => {
|
for (const row of payload) {
|
||||||
const wrapper = row as Record<string, unknown>;
|
const wrapper = row as Record<string, unknown>;
|
||||||
const participant = (typeof wrapper.participant === 'object' && wrapper.participant !== null)
|
const participant =
|
||||||
? (wrapper.participant as Record<string, unknown>)
|
typeof wrapper.participant === 'object' && wrapper.participant !== null
|
||||||
: wrapper;
|
? (wrapper.participant as Record<string, unknown>)
|
||||||
|
: 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 root = payload as Record<string, unknown>;
|
const data = (payload as Record<string, unknown>).data;
|
||||||
const data = root.data;
|
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
data.forEach((row) => {
|
for (const row of data) {
|
||||||
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());
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => {
|
// ─── Utilidades ────────────────────────────────────────────────────────────────
|
||||||
if (oauthCallbackServer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const callbackUrl = getCallbackUrl(oauthConfig.callbackPort);
|
const getStringProp = (payload: unknown, key: string): string => {
|
||||||
|
if (typeof payload !== 'object' || payload === null || !(key in payload)) return '';
|
||||||
const server = createServer((req, res) => {
|
const value = (payload as Record<string, unknown>)[key];
|
||||||
if (!req.url) {
|
return typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||||
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 sendAck = (ack: unknown, error: string | null, response?: unknown) => {
|
||||||
const oauthConfig = getOAuthConfig();
|
if (typeof ack === 'function') ack(error, response);
|
||||||
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;
|
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listenForMessage(messageNames.integrations.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 {
|
try {
|
||||||
await ensureOAuthCallbackServer(oauthConfig);
|
await oauthServer.ensureServer(serverConfig);
|
||||||
} catch (serverError) {
|
} catch (err) {
|
||||||
const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback';
|
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
|
||||||
sendAck(ack, message);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupExpiredOAuthSessions();
|
sendAck(ack, null, oauthServer.createSession(serverConfig));
|
||||||
|
|
||||||
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) => {
|
listenForMessage(messageNames.integrations.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 session = oauthSessions.get(sessionId);
|
const status = oauthServer.getSessionStatus(sessionId);
|
||||||
if (!session) {
|
if (!status) {
|
||||||
sendAck(ack, 'OAuth session not found');
|
sendAck(ack, 'OAuth session not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendAck(ack, null, {
|
sendAck(ack, null, status);
|
||||||
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) => {
|
listenForMessage(messageNames.integrations.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;
|
||||||
@@ -528,35 +482,27 @@ 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, 20);
|
.slice(0, RECENT_TOURNAMENTS_LIMIT);
|
||||||
|
|
||||||
sendAck(ack, null, tournaments);
|
sendAck(ack, null, tournaments);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments';
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
||||||
sendAck(ack, message);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ack) => {
|
listenForMessage(messageNames.integrations.challonge.fetchTournamentPlayers, async (payload: unknown, ack) => {
|
||||||
const token = getStringProp(payload, 'token');
|
const token = getStringProp(payload, 'token');
|
||||||
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
|
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
|
||||||
|
|
||||||
if (!token) {
|
if (!token) { sendAck(ack, 'Missing Challonge API token'); return; }
|
||||||
sendAck(ack, 'Missing Challonge API token');
|
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!slug) {
|
|
||||||
sendAck(ack, 'Missing tournament slug');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = await requestChallonge(`/tournaments/${encodeURIComponent(slug)}/participants.json`, token);
|
const raw = await requestChallonge(
|
||||||
const players = parseImportedPlayers(raw);
|
`/tournaments/${encodeURIComponent(slug)}/participants.json`,
|
||||||
sendAck(ack, null, players);
|
token,
|
||||||
|
);
|
||||||
|
sendAck(ack, null, parseImportedPlayers(raw));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error while importing players';
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
||||||
sendAck(ack, message);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { ExampleType } from '../types/index.js';
|
|
||||||
import { nodecg } from './util/nodecg.js';
|
|
||||||
import { exampleReplicant } from './util/replicants.js';
|
|
||||||
|
|
||||||
// Example code:
|
|
||||||
// Log to show things are working.
|
|
||||||
nodecg.log.info('Extension code working!');
|
|
||||||
// Access this bundle's configuration with no type assertion needed.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const configProperty = nodecg.bundleConfig.exampleProperty;
|
|
||||||
// Access/set a replicant (also see ./util/replicants).
|
|
||||||
exampleReplicant.value = { exampleProperty: `exampleString_Changed_${Date.now()}` };
|
|
||||||
// Accessing normal types.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const exampleType: ExampleType = { exampleProperty: 'exampleString' };
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { NodeCGServerAPI } from '../types/index.js';
|
import type { NodeCGServerAPI } from '../types/index.js';
|
||||||
import { set } from './util/nodecg.js';
|
import { setNodecgContext } from '../nodecg/extension/context.js';
|
||||||
|
|
||||||
export default async (nodecg: NodeCGServerAPI) => {
|
export default async (nodecg: NodeCGServerAPI) => {
|
||||||
/**
|
/**
|
||||||
* Because of how top-level `import`s work, it helps to use `import`s here
|
* Because of how top-level `import`s work, it helps to use `import`s here
|
||||||
* to force things to be loaded *after* the NodeCG context is set.
|
* to force things to be loaded *after* the NodeCG context is set.
|
||||||
*/
|
*/
|
||||||
set(nodecg); // set nodecg "context" before anything else
|
setNodecgContext(nodecg); // set nodecg "context" before anything else
|
||||||
await import('./util/replicants.js'); // make sure replicants are set up
|
await import('./modules/replicants.js'); // make sure replicants are set up
|
||||||
await import('./example.js');
|
|
||||||
await import('./startgg.js');
|
await import('./startgg.js');
|
||||||
await import('./challonge.js');
|
await import('./challonge.js');
|
||||||
|
await import('./pack-manager.js');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import {
|
||||||
|
commentaryReplicant,
|
||||||
|
playersReplicant,
|
||||||
|
scoreboardReplicant,
|
||||||
|
} from '../../nodecg/extension/replicants.js';
|
||||||
|
|
||||||
|
playersReplicant();
|
||||||
|
scoreboardReplicant();
|
||||||
|
commentaryReplicant();
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
// src/extension/pack-manager.ts
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Módulo autocontenido: no importa nada de src/shared/ para respetar el
|
||||||
|
// rootDir del tsconfig de la extensión. Las constantes de Gitea y los tipos
|
||||||
|
// necesarios están definidos aquí directamente.
|
||||||
|
//
|
||||||
|
// Para activarlo, añade UNA línea en src/extension/index.ts:
|
||||||
|
// await import('./pack-manager.js');
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'http';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { nodecg } from '../nodecg/extension/context.js';
|
||||||
|
import { listenForMessage, reply, type Acknowledgement } from '../nodecg/extension/messages.js';
|
||||||
|
import { createPackExtensionReplicants } from '../nodecg/extension/packReplicants.js';
|
||||||
|
import { messageNames } from '../nodecg/messageNames.js';
|
||||||
|
import type {
|
||||||
|
PackDownloadState,
|
||||||
|
PackManifest,
|
||||||
|
PackRegistry,
|
||||||
|
} from '../shared/domain/packs/types.js';
|
||||||
|
|
||||||
|
// ── Configuración de Gitea ────────────────────────────────────────────────────
|
||||||
|
// Edita estas constantes para apuntar a tu instancia.
|
||||||
|
|
||||||
|
const GITEA_BASE_URL = 'http://10.0.0.10:3002';
|
||||||
|
const GITEA_OWNER = 'Pandipipas';
|
||||||
|
const GITEA_REPO = 'fighting-game-packs';
|
||||||
|
const GITEA_BRANCH = 'main';
|
||||||
|
|
||||||
|
const rawUrl = (repoPath: string) =>
|
||||||
|
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
|
||||||
|
|
||||||
|
const REGISTRY_URL = rawUrl('registry.json');
|
||||||
|
const getManifestUrl = (id: string) => rawUrl(`${id}/manifest.json`);
|
||||||
|
const getPackLogoUrl = (id: string) => rawUrl(`${id}/logo.png`);
|
||||||
|
const getCharacterImageRepoUrl = (id: string, slug: string, ext: string) =>
|
||||||
|
rawUrl(`${id}/characters/${slug}.${ext}`);
|
||||||
|
|
||||||
|
// ── Tipos locales ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Replicamos la forma exacta del tipo Acknowledgement de NodeCG sin necesidad
|
||||||
|
// de importar @nodecg/types. HandledAcknowledgement NO es callable (es un objeto),
|
||||||
|
// UnhandledAcknowledgement SÍ lo es. El helper reply() comprueba cuál es antes de llamar.
|
||||||
|
// ── Constantes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'avif'] as const;
|
||||||
|
|
||||||
|
|
||||||
|
// Raíz del proyecto: 2 niveles por encima de extension/pack-manager.js
|
||||||
|
// Usamos import.meta.url porque nodecg.bundleDir no está disponible cuando
|
||||||
|
// NodeCG se usa como dependencia en lugar de servidor standalone.
|
||||||
|
const bundleDir = fileURLToPath(new URL('../', import.meta.url));
|
||||||
|
|
||||||
|
// ── Replicants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const {
|
||||||
|
installedPacksRep,
|
||||||
|
packRegistryRep,
|
||||||
|
downloadStatesRep,
|
||||||
|
availableUpdatesRep,
|
||||||
|
} = createPackExtensionReplicants();
|
||||||
|
|
||||||
|
// ── Filesystem ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const packsDir = path.join(bundleDir, 'packs');
|
||||||
|
fs.mkdirSync(packsDir, { recursive: true });
|
||||||
|
nodecg.log.info(`[pack-manager] Packs directory: ${packsDir}`);
|
||||||
|
|
||||||
|
// Registrar el directorio de packs como ruta estática usando nodecg.mount().
|
||||||
|
// Las imágenes quedan accesibles en /packs/<packId>/characters/<slug>.png
|
||||||
|
// independientemente de cómo NodeCG configure el resto de rutas del bundle.
|
||||||
|
const packsMiddleware = (req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
const urlPath = decodeURIComponent(req.url ?? '/');
|
||||||
|
const safe = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, '');
|
||||||
|
const file = path.join(packsDir, safe);
|
||||||
|
|
||||||
|
// Security: only serve files inside packsDir
|
||||||
|
if (!file.startsWith(packsDir)) {
|
||||||
|
res.writeHead(403);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.stat(file, (statErr, stat) => {
|
||||||
|
if (statErr || !stat.isFile()) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.json': 'application/json',
|
||||||
|
};
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
res.setHeader('Content-Type', mimeTypes[ext] ?? 'application/octet-stream');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||||
|
fs.createReadStream(file).pipe(res);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// nodecg.mount registra el middleware en el servidor Express de NodeCG
|
||||||
|
(nodecg as unknown as { mount: (p: string, h: typeof packsMiddleware) => void })
|
||||||
|
.mount('/packs', packsMiddleware);
|
||||||
|
|
||||||
|
// Verificación de integridad al arrancar
|
||||||
|
const installedAtStart = installedPacksRep.value ?? [];
|
||||||
|
const verified = installedAtStart.filter((id) =>
|
||||||
|
fs.existsSync(path.join(packsDir, id, 'manifest.json')),
|
||||||
|
);
|
||||||
|
if (verified.length !== installedAtStart.length) {
|
||||||
|
nodecg.log.warn('[pack-manager] Algunos packs instalados no estaban en disco y se han eliminado del registro.');
|
||||||
|
installedPacksRep.value = verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers internos ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const setDownloadState = (packId: string, patch: Partial<PackDownloadState>): void => {
|
||||||
|
const current = downloadStatesRep.value?.[packId] ?? { status: 'idle', progress: 0 };
|
||||||
|
downloadStatesRep.value = {
|
||||||
|
...downloadStatesRep.value,
|
||||||
|
[packId]: { ...current, ...patch },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBuffer = async (url: string): Promise<Buffer> => {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status} — ${url}`);
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
};
|
||||||
|
|
||||||
|
const trySaveImage = async (
|
||||||
|
destDir: string,
|
||||||
|
filename: string,
|
||||||
|
extensions: readonly string[],
|
||||||
|
buildUrl: (ext: string) => string,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
for (const ext of extensions) {
|
||||||
|
try {
|
||||||
|
const buffer = await fetchBuffer(buildUrl(ext));
|
||||||
|
// Siempre guardamos como .png para que la URL del dashboard sea predecible.
|
||||||
|
// Los navegadores modernos identifican el formato por el contenido (magic bytes),
|
||||||
|
// no por la extensión, así que WebP/AVIF/JPEG se renderizan correctamente.
|
||||||
|
fs.writeFileSync(path.join(destDir, `${filename}.png`), buffer);
|
||||||
|
return true;
|
||||||
|
} catch { /* prueba siguiente extensión */ }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Detección de actualizaciones ─────────────────────────────────────────────
|
||||||
|
// Compara la versión en el manifest.json local de cada pack instalado contra
|
||||||
|
// la versión en el registro de Gitea. Solo aplica a packs descargados (no bundled).
|
||||||
|
|
||||||
|
const checkForUpdates = (): void => {
|
||||||
|
const registry = packRegistryRep.value;
|
||||||
|
const installed = installedPacksRep.value ?? [];
|
||||||
|
|
||||||
|
if (!registry || installed.length === 0) {
|
||||||
|
availableUpdatesRep.value = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Record<string, { installedVersion: string; latestVersion: string }> = {};
|
||||||
|
|
||||||
|
for (const packId of installed) {
|
||||||
|
const registryEntry = registry.packs.find((p) => p.id === packId);
|
||||||
|
if (!registryEntry) continue;
|
||||||
|
|
||||||
|
const manifestPath = path.join(packsDir, packId, 'manifest.json');
|
||||||
|
try {
|
||||||
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as PackManifest;
|
||||||
|
if (manifest.version !== registryEntry.version) {
|
||||||
|
updates[packId] = {
|
||||||
|
installedVersion: manifest.version,
|
||||||
|
latestVersion: registryEntry.version,
|
||||||
|
};
|
||||||
|
nodecg.log.info(
|
||||||
|
`[pack-manager] Actualización disponible para "${packId}": ${manifest.version} → ${registryEntry.version}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Manifest ilegible — ignorar este pack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
availableUpdatesRep.value = updates;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Comprobar al arrancar si ya hay un registro cacheado
|
||||||
|
checkForUpdates();
|
||||||
|
|
||||||
|
// ── Mensaje: fetchPackRegistry ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listenForMessage(messageNames.packs.fetchRegistry, async (_data: unknown, ack: Acknowledgement | undefined) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(REGISTRY_URL);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const registry = await response.json() as PackRegistry;
|
||||||
|
packRegistryRep.value = registry;
|
||||||
|
checkForUpdates(); // re-evaluar actualizaciones con el registro nuevo
|
||||||
|
reply(ack, null, registry);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
nodecg.log.error(`[pack-manager] Error al obtener el registro: ${message}`);
|
||||||
|
reply(ack, new Error(message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mensaje: downloadPack ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listenForMessage(messageNames.packs.download, async (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||||
|
if (typeof packId !== 'string' || !packId) {
|
||||||
|
return reply(ack, new Error('downloadPack requiere un packId no vacío.'));
|
||||||
|
}
|
||||||
|
if (installedPacksRep.value?.includes(packId)) {
|
||||||
|
return reply(ack, null, { alreadyInstalled: true });
|
||||||
|
}
|
||||||
|
if (downloadStatesRep.value?.[packId]?.status === 'downloading') {
|
||||||
|
return reply(ack, new Error(`El pack "${packId}" ya se está descargando.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadState(packId, { status: 'fetching-manifest', progress: 0, error: undefined });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manifestRes = await fetch(getManifestUrl(packId));
|
||||||
|
if (!manifestRes.ok) throw new Error(`No se puede obtener el manifest: HTTP ${manifestRes.status}`);
|
||||||
|
const manifest = await manifestRes.json() as PackManifest;
|
||||||
|
|
||||||
|
const packDir = path.join(packsDir, packId);
|
||||||
|
const charsDir = path.join(packDir, 'characters');
|
||||||
|
fs.mkdirSync(charsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(packDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||||
|
|
||||||
|
setDownloadState(packId, { status: 'downloading', progress: 2 });
|
||||||
|
try {
|
||||||
|
const logoBuffer = await fetchBuffer(getPackLogoUrl(packId));
|
||||||
|
fs.writeFileSync(path.join(packDir, 'logo.png'), logoBuffer);
|
||||||
|
} catch {
|
||||||
|
nodecg.log.warn(`[pack-manager] No se encontró logo para "${packId}" — se omite.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = manifest.characters.length;
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const char = manifest.characters[i]!;
|
||||||
|
const saved = await trySaveImage(
|
||||||
|
charsDir,
|
||||||
|
char.slug,
|
||||||
|
IMAGE_EXTENSIONS,
|
||||||
|
(ext) => getCharacterImageRepoUrl(packId, char.slug, ext),
|
||||||
|
);
|
||||||
|
if (!saved) {
|
||||||
|
nodecg.log.warn(`[pack-manager] Sin imagen para "${packId}/${char.slug}" — se usará placeholder.`);
|
||||||
|
}
|
||||||
|
setDownloadState(packId, { progress: 5 + Math.round(((i + 1) / total) * 93) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = installedPacksRep.value ?? [];
|
||||||
|
if (!current.includes(packId)) installedPacksRep.value = [...current, packId];
|
||||||
|
|
||||||
|
setDownloadState(packId, { status: 'done', progress: 100 });
|
||||||
|
reply(ack, null, { packId, characterCount: manifest.characters.length });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
nodecg.log.error(`[pack-manager] Error al descargar "${packId}": ${message}`);
|
||||||
|
setDownloadState(packId, { status: 'error', error: message });
|
||||||
|
reply(ack, new Error(message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mensaje: uninstallPack ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listenForMessage(messageNames.packs.uninstall, (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||||
|
if (typeof packId !== 'string' || !packId) {
|
||||||
|
return reply(ack, new Error('uninstallPack requiere un packId no vacío.'));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.rmSync(path.join(packsDir, packId), { recursive: true, force: true });
|
||||||
|
installedPacksRep.value = (installedPacksRep.value ?? []).filter((id) => id !== packId);
|
||||||
|
const states = { ...downloadStatesRep.value };
|
||||||
|
delete states[packId];
|
||||||
|
downloadStatesRep.value = states;
|
||||||
|
const updates = { ...availableUpdatesRep.value };
|
||||||
|
delete updates[packId];
|
||||||
|
availableUpdatesRep.value = updates;
|
||||||
|
reply(ack, null);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
nodecg.log.error(`[pack-manager] Error al desinstalar "${packId}": ${message}`);
|
||||||
|
reply(ack, new Error(message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mensaje: updatePack ──────────────────────────────────────────────────────
|
||||||
|
// Dashboard → Extension: "Actualiza el pack <packId> a la última versión."
|
||||||
|
// Borra las imágenes antiguas y descarga las nuevas desde Gitea.
|
||||||
|
|
||||||
|
listenForMessage(messageNames.packs.update, async (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||||
|
if (typeof packId !== 'string' || !packId) {
|
||||||
|
return reply(ack, new Error('updatePack requiere un packId no vacío.'));
|
||||||
|
}
|
||||||
|
if (!installedPacksRep.value?.includes(packId)) {
|
||||||
|
return reply(ack, new Error(`El pack "${packId}" no está instalado. Usa downloadPack primero.`));
|
||||||
|
}
|
||||||
|
if (downloadStatesRep.value?.[packId]?.status === 'downloading') {
|
||||||
|
return reply(ack, new Error(`El pack "${packId}" ya se está actualizando.`));
|
||||||
|
}
|
||||||
|
|
||||||
|
setDownloadState(packId, { status: 'fetching-manifest', progress: 0, error: undefined });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Obtener nuevo manifest
|
||||||
|
const manifestRes = await fetch(getManifestUrl(packId));
|
||||||
|
if (!manifestRes.ok) throw new Error(`No se puede obtener el manifest: HTTP ${manifestRes.status}`);
|
||||||
|
const manifest = await manifestRes.json() as PackManifest;
|
||||||
|
|
||||||
|
const packDir = path.join(packsDir, packId);
|
||||||
|
const charsDir = path.join(packDir, 'characters');
|
||||||
|
|
||||||
|
// 2. Limpiar imágenes antiguas para evitar residuos de personajes renombrados
|
||||||
|
if (fs.existsSync(charsDir)) {
|
||||||
|
fs.rmSync(charsDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
fs.mkdirSync(charsDir, { recursive: true });
|
||||||
|
|
||||||
|
// 3. Guardar nuevo manifest en disco
|
||||||
|
fs.writeFileSync(path.join(packDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
||||||
|
|
||||||
|
// 4. Logo
|
||||||
|
setDownloadState(packId, { status: 'downloading', progress: 2 });
|
||||||
|
try {
|
||||||
|
const logoBuffer = await fetchBuffer(getPackLogoUrl(packId));
|
||||||
|
fs.writeFileSync(path.join(packDir, 'logo.png'), logoBuffer);
|
||||||
|
} catch {
|
||||||
|
nodecg.log.warn(`[pack-manager] No se encontró logo para "${packId}" — se omite.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Imágenes de personajes
|
||||||
|
const total = manifest.characters.length;
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const char = manifest.characters[i]!;
|
||||||
|
const saved = await trySaveImage(
|
||||||
|
charsDir,
|
||||||
|
char.slug,
|
||||||
|
IMAGE_EXTENSIONS,
|
||||||
|
(ext) => getCharacterImageRepoUrl(packId, char.slug, ext),
|
||||||
|
);
|
||||||
|
if (!saved) {
|
||||||
|
nodecg.log.warn(`[pack-manager] Sin imagen para "${packId}/${char.slug}" — se usará placeholder.`);
|
||||||
|
}
|
||||||
|
setDownloadState(packId, { progress: 5 + Math.round(((i + 1) / total) * 93) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Quitar de availableUpdates
|
||||||
|
const updates = { ...availableUpdatesRep.value };
|
||||||
|
delete updates[packId];
|
||||||
|
availableUpdatesRep.value = updates;
|
||||||
|
|
||||||
|
setDownloadState(packId, { status: 'done', progress: 100 });
|
||||||
|
nodecg.log.info(`[pack-manager] Pack "${packId}" actualizado a v${manifest.version}.`);
|
||||||
|
reply(ack, null, { packId, version: manifest.version });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
nodecg.log.error(`[pack-manager] Error al actualizar "${packId}": ${message}`);
|
||||||
|
setDownloadState(packId, { status: 'error', error: message });
|
||||||
|
reply(ack, new Error(message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Mensaje: readLocalManifest ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listenForMessage(messageNames.packs.readLocalManifest, (packId: unknown, ack: Acknowledgement | undefined) => {
|
||||||
|
if (typeof packId !== 'string' || !packId) {
|
||||||
|
return reply(ack, new Error('readLocalManifest requiere un packId no vacío.'));
|
||||||
|
}
|
||||||
|
const manifestPath = path.join(packsDir, packId, 'manifest.json');
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(manifestPath, 'utf-8');
|
||||||
|
reply(ack, null, JSON.parse(raw) as PackManifest);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
reply(ack, new Error(`No se puede leer el manifest de "${packId}": ${message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
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 '../nodecg/extension/context.js';
|
||||||
|
import { listenForMessage } from '../nodecg/extension/messages.js';
|
||||||
|
import { messageNames } from '../nodecg/messageNames.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';
|
||||||
@@ -17,6 +20,16 @@ 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 }>;
|
||||||
@@ -39,21 +52,6 @@ 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;
|
||||||
@@ -61,31 +59,163 @@ interface OAuthTokenResponse {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oauthSessions = new Map<string, OAuthSession>();
|
// ─── Modo OAuth ────────────────────────────────────────────────────────────────
|
||||||
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.
|
||||||
|
|
||||||
const getStringProp = (payload: unknown, key: string): string => {
|
type OAuthMode =
|
||||||
if (typeof payload !== 'object' || payload === null || !(key in payload)) {
|
| { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
|
||||||
return '';
|
| { type: 'proxy'; proxyBaseUrl: string; callbackPort: number };
|
||||||
|
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const value = (payload as Record<string, unknown>)[key];
|
nodecg.log.info(`[start.gg] OAuth: modo proxy → ${proxyBaseUrl}`);
|
||||||
return typeof value === 'string' ? value.trim() : String(value || '').trim();
|
return { type: 'proxy', proxyBaseUrl, callbackPort };
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateOAuthSession = (sessionId: string, update: Partial<OAuthSession>) => {
|
// ─── Exchange de token ─────────────────────────────────────────────────────────
|
||||||
const session = oauthSessions.get(sessionId);
|
|
||||||
if (!session) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
oauthSessions.set(sessionId, {
|
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
|
||||||
...session,
|
const rawBody = await response.text();
|
||||||
...update,
|
try {
|
||||||
|
return JSON.parse(rawBody) as OAuthTokenResponse;
|
||||||
|
} catch {
|
||||||
|
return { message: rawBody };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Modo dev: exchange directo con start.gg usando credenciales locales */
|
||||||
|
const exchangeCodeDirectly = async (
|
||||||
|
code: string,
|
||||||
|
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestStartGG = async <T>(query: string, variables: Record<string, unknown>, token: string): Promise<T> => {
|
/** 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/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> => {
|
||||||
|
void _config;
|
||||||
|
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: {
|
||||||
@@ -107,7 +237,7 @@ const requestStartGG = async <T>(query: string, variables: Record<string, unknow
|
|||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -117,286 +247,92 @@ const requestStartGG = async <T>(query: string, variables: Record<string, unknow
|
|||||||
return payload.data;
|
return payload.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Resolución de países ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const countries = getData();
|
const countries = getData();
|
||||||
const countryByCode = new Set(countries.map((country: CountryRecord) => country.code.toUpperCase()));
|
const countryByCode = new Set(countries.map((c: CountryRecord) => c.code.toUpperCase()));
|
||||||
const countryByName = new Map(countries.map((country: CountryRecord) => [country.name.toLowerCase(), country.code.toUpperCase()]));
|
const countryByName = new Map(
|
||||||
|
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) {
|
if (!raw) return '';
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const upper = raw.toUpperCase();
|
const upper = raw.toUpperCase();
|
||||||
if (countryByCode.has(upper)) {
|
if (countryByCode.has(upper)) return 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') {
|
if (typeof ack === 'function') ack(error, response);
|
||||||
return;
|
|
||||||
}
|
|
||||||
ack(error, response);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOAuthConfig = (): OAuthConfig | null => {
|
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
|
||||||
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
|
|
||||||
const clientId = String(bundleConfig.startggClientId || '').trim();
|
|
||||||
const clientSecret = String(bundleConfig.startggClientSecret || '').trim();
|
|
||||||
const rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT);
|
|
||||||
const callbackPort = Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT;
|
|
||||||
|
|
||||||
if (!clientId || !clientSecret) {
|
listenForMessage(messageNames.integrations.startgg.createOAuthSession, async (_payload: unknown, ack) => {
|
||||||
return null;
|
const mode = getOAuthMode();
|
||||||
}
|
let serverConfig: OAuthConfig;
|
||||||
|
|
||||||
return {
|
if (mode.type === 'dev') {
|
||||||
clientId,
|
serverConfig = {
|
||||||
clientSecret,
|
clientId: mode.clientId,
|
||||||
callbackPort,
|
callbackPort: mode.callbackPort,
|
||||||
};
|
};
|
||||||
};
|
} else {
|
||||||
|
// Modo proxy: el clientId viene del Worker.
|
||||||
const getCallbackUrl = (callbackPort: number) => `http://127.0.0.1:${callbackPort}${STARTGG_OAUTH_CALLBACK_PATH}`;
|
// Es público (va en la URL del navegador), pero no lo queremos en el repo.
|
||||||
|
try {
|
||||||
const cleanupExpiredOAuthSessions = () => {
|
const res = await fetch(`${mode.proxyBaseUrl}/oauth/startgg/client-id`);
|
||||||
const now = Date.now();
|
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
|
||||||
oauthSessions.forEach((session, sessionId) => {
|
const data = await res.json() as { clientId?: string };
|
||||||
if (session.expiresAt <= now && session.status === 'pending') {
|
const clientId = String(data.clientId ?? '').trim();
|
||||||
updateOAuthSession(sessionId, { status: 'expired' });
|
if (!clientId) throw new Error('Proxy did not return a clientId');
|
||||||
}
|
serverConfig = { clientId, callbackPort: mode.callbackPort };
|
||||||
});
|
} catch (err) {
|
||||||
};
|
sendAck(
|
||||||
|
ack,
|
||||||
const respondWithCallbackHtml = (res: ServerResponse, statusCode: number, title: string, message: string) => {
|
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
|
||||||
res.statusCode = statusCode;
|
);
|
||||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
||||||
res.end(renderCallbackHtml(title, message));
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCallbackHtml = (title: string, message: string) => `<!doctype html>
|
|
||||||
<html lang="es">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>${title}</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: Arial, sans-serif; margin: 2rem; background: #121212; color: #fff; }
|
|
||||||
.box { max-width: 680px; padding: 1rem 1.2rem; border: 1px solid #444; border-radius: 8px; }
|
|
||||||
.ok { color: #66bb6a; }
|
|
||||||
.ko { color: #ef5350; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="box">
|
|
||||||
<h2>${title}</h2>
|
|
||||||
<p>${message}</p>
|
|
||||||
<p>You can close this tab and return to Scoreko.</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
|
|
||||||
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
|
|
||||||
const rawBody = await response.text();
|
|
||||||
try {
|
|
||||||
return JSON.parse(rawBody) as OAuthTokenResponse;
|
|
||||||
} catch {
|
|
||||||
return { message: rawBody };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const exchangeOAuthCodeForToken = async (
|
|
||||||
code: string,
|
|
||||||
redirectUri: string,
|
|
||||||
oauthConfig: OAuthConfig,
|
|
||||||
): Promise<string> => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
code,
|
|
||||||
client_id: oauthConfig.clientId,
|
|
||||||
client_secret: oauthConfig.clientSecret,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
});
|
|
||||||
|
|
||||||
let lastError = 'Unknown OAuth token exchange error';
|
|
||||||
|
|
||||||
for (const tokenEndpoint of STARTGG_OAUTH_TOKEN_ENDPOINTS) {
|
|
||||||
const response = await fetch(tokenEndpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
},
|
|
||||||
body: params.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = await parseOAuthTokenPayload(response);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const token = String(payload.access_token || '').trim();
|
|
||||||
if (token) {
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastError = payload.error_description || payload.error || payload.message || 'OAuth token response did not include an access token';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastError = payload.error_description || payload.error || payload.message || `OAuth token request failed (${response.status})`;
|
|
||||||
|
|
||||||
if (response.status !== 404) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(lastError);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureOAuthCallbackServer = async (oauthConfig: OAuthConfig) => {
|
|
||||||
if (oauthCallbackServer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const callbackUrl = getCallbackUrl(oauthConfig.callbackPort);
|
|
||||||
|
|
||||||
const server = createServer((req, res) => {
|
|
||||||
if (!req.url) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
res.end('Bad request');
|
|
||||||
return;
|
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 {
|
try {
|
||||||
await ensureOAuthCallbackServer(oauthConfig);
|
await oauthServer.ensureServer(serverConfig);
|
||||||
} catch (serverError) {
|
} catch (err) {
|
||||||
const message = serverError instanceof Error ? serverError.message : 'Could not start the local OAuth callback';
|
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
|
||||||
sendAck(ack, message);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupExpiredOAuthSessions();
|
sendAck(ack, null, oauthServer.createSession(serverConfig));
|
||||||
|
|
||||||
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) => {
|
listenForMessage(messageNames.integrations.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 session = oauthSessions.get(sessionId);
|
const status = oauthServer.getSessionStatus(sessionId);
|
||||||
if (!session) {
|
if (!status) {
|
||||||
sendAck(ack, 'OAuth session not found');
|
sendAck(ack, 'OAuth session not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendAck(ack, null, {
|
sendAck(ack, null, status);
|
||||||
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) => {
|
listenForMessage(messageNames.integrations.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;
|
||||||
@@ -423,37 +359,24 @@ 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 = data.currentUser?.tournaments.nodes
|
const tournaments =
|
||||||
.filter((item) => item.slug)
|
data.currentUser?.tournaments.nodes
|
||||||
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
.filter((item) => item.slug)
|
||||||
.map((item) => ({
|
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
|
||||||
id: item.id,
|
.map(({ id, name, slug, startAt, endAt }) => ({ id, name, slug, startAt, endAt })) ?? [];
|
||||||
name: item.name,
|
|
||||||
slug: item.slug,
|
|
||||||
startAt: item.startAt,
|
|
||||||
endAt: item.endAt,
|
|
||||||
})) ?? [];
|
|
||||||
|
|
||||||
sendAck(ack, null, tournaments);
|
sendAck(ack, null, tournaments);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments';
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while loading tournaments');
|
||||||
sendAck(ack, message);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => {
|
listenForMessage(messageNames.integrations.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) {
|
if (!token) { sendAck(ack, 'Missing start.gg API token'); return; }
|
||||||
sendAck(ack, 'Missing start.gg API token');
|
if (!slug) { sendAck(ack, 'Missing tournament slug'); return; }
|
||||||
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!) {
|
||||||
@@ -491,50 +414,37 @@ 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: {
|
user: { location: { country: string | null } | null } | null;
|
||||||
location: {
|
|
||||||
country: string | null;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
} | null;
|
} | null;
|
||||||
}>(query, {
|
}>(query, { slug, page: currentPage, perPage: PARTICIPANTS_PAGE_SIZE }, token);
|
||||||
slug,
|
|
||||||
page: currentPage,
|
|
||||||
perPage: PARTICIPANTS_PAGE_SIZE,
|
|
||||||
}, token);
|
|
||||||
|
|
||||||
if (!data.tournament) {
|
if (!data.tournament) throw new Error('Tournament not found');
|
||||||
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;
|
||||||
|
|
||||||
data.tournament.participants.nodes.forEach((participant) => {
|
for (const participant of data.tournament.participants.nodes) {
|
||||||
const playerId = String(participant.id);
|
const playerId = String(participant.id);
|
||||||
const gamertag = (participant.gamerTag || '').trim();
|
const gamertag = (participant.gamerTag ?? '').trim();
|
||||||
if (!gamertag) {
|
if (!gamertag) continue;
|
||||||
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,
|
country: resolveCountryCodeFromStartGG(participant.user?.location?.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) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error while importing players';
|
sendAck(ack, error instanceof Error ? error.message : 'Unknown error while importing players');
|
||||||
sendAck(ack, message);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { NodeCGServerAPI } from '../../types/index.js';
|
|
||||||
|
|
||||||
export let nodecg!: NodeCGServerAPI;
|
|
||||||
|
|
||||||
export function set(ctx: NodeCGServerAPI) {
|
|
||||||
nodecg = ctx;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
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 };
|
||||||
|
};
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type NodeCG from 'nodecg/types';
|
|
||||||
import type { Schemas } from '../../types/index.js';
|
|
||||||
import { nodecg } from './nodecg.js';
|
|
||||||
|
|
||||||
// Wrapper for replicants that have a default (based on schema).
|
|
||||||
function hasDefault<T>(name: string) {
|
|
||||||
return nodecg.Replicant<T>(name) as unknown as NodeCG.default.ServerReplicantWithSchemaDefault<T>;
|
|
||||||
}
|
|
||||||
// Wrapper for replicants that don't have a default (based on schema).
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
function hasNoDefault<T>(name: string) {
|
|
||||||
return nodecg.Replicant<T>(name) as NodeCG.default.ServerReplicant<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is where you can declare all of your replicants to import easily into other files,
|
|
||||||
* and to make sure they have any correct settings on startup.
|
|
||||||
*/
|
|
||||||
export const exampleReplicant = hasDefault<Schemas.ExampleReplicant>('exampleReplicant');
|
|
||||||
export const playersReplicant = hasDefault<Schemas.Players>('players');
|
|
||||||
export const scoreboardReplicant = hasDefault<Schemas.Scoreboard>('scoreboard');
|
|
||||||
|
|
||||||
export const commentaryReplicant = nodecg.Replicant<Schemas.Commentary>('commentary', {
|
|
||||||
defaultValue: {
|
|
||||||
leftCommentator: '',
|
|
||||||
leftCommentatorTwitter: '',
|
|
||||||
rightCommentator: '',
|
|
||||||
rightCommentatorTwitter: '',
|
|
||||||
},
|
|
||||||
persistent: false,
|
|
||||||
});
|
|
||||||
@@ -1,19 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useHead } from '@unhead/vue';
|
import { useHead } from '@unhead/vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { commentaryReplicant } from '../../browser_shared/replicants';
|
import { useCommentaryReplicatedState } from '../shared/services/replicated-state';
|
||||||
import type { Schemas } from '../../types';
|
|
||||||
|
|
||||||
useHead({ title: 'Commentary' });
|
useHead({ title: 'Commentary' });
|
||||||
|
|
||||||
const defaultCommentary: Schemas.Commentary = {
|
const { commentary } = useCommentaryReplicatedState();
|
||||||
leftCommentator: '',
|
|
||||||
leftCommentatorTwitter: '',
|
|
||||||
rightCommentator: '',
|
|
||||||
rightCommentatorTwitter: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const commentary = computed<Schemas.Commentary>(() => commentaryReplicant?.data ?? defaultCommentary);
|
|
||||||
|
|
||||||
const leftCommentator = computed(() => commentary.value.leftCommentator || 'COMMENTATOR 1');
|
const leftCommentator = computed(() => commentary.value.leftCommentator || 'COMMENTATOR 1');
|
||||||
const rightCommentator = computed(() => commentary.value.rightCommentator || 'COMMENTATOR 2');
|
const rightCommentator = computed(() => commentary.value.rightCommentator || 'COMMENTATOR 2');
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useHead } from '@unhead/vue';
|
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 { useScoreboardReplicatedState } from '../shared/services/replicated-state';
|
||||||
import { resolveCountryCode } from '../../shared/countries';
|
import { resolveCountryCode } from '../../shared/domain/players/countries';
|
||||||
import { getCharactersByGame } from '../../shared/fighting-characters';
|
import { getCharactersByGame } from '../../shared/fighting-characters';
|
||||||
import type { Schemas } from '../../types';
|
|
||||||
|
|
||||||
useHead({ title: 'Scoreboard 2XKO' });
|
useHead({ title: 'Scoreboard 2XKO' });
|
||||||
|
|
||||||
const defaultScoreboard: Schemas.Scoreboard = {
|
const { players, scoreboard, scoreboardSkin } = useScoreboardReplicatedState('scoreboard-2xko/main.html');
|
||||||
leftPlayerId: '', rightPlayerId: '', leftNameOverride: '', rightNameOverride: '', leftTeamOverride: '', rightTeamOverride: '',
|
|
||||||
leftCountryOverride: '', rightCountryOverride: '', leftCharacter: '', rightCharacter: '', leftScore: 0, rightScore: 0, round: '', game: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
|
|
||||||
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
|
|
||||||
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? 'scoreboard-2xko/main.html');
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
scoreboardSkin,
|
scoreboardSkin,
|
||||||
|
|||||||
@@ -1,32 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useHead } from '@unhead/vue';
|
import { useHead } from '@unhead/vue';
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
|
import { useScoreboardReplicatedState } from '../shared/services/replicated-state';
|
||||||
import { resolveCountryCode } from '../../shared/countries';
|
import { resolveCountryCode } from '../../shared/domain/players/countries';
|
||||||
import type { Schemas } from '../../types';
|
|
||||||
|
|
||||||
useHead({ title: 'Scoreboard' });
|
useHead({ title: 'Scoreboard' });
|
||||||
|
|
||||||
const defaultScoreboard: Schemas.Scoreboard = {
|
const { players, scoreboard, scoreboardSkin } = useScoreboardReplicatedState('scoreboard/main.html');
|
||||||
leftPlayerId: '',
|
|
||||||
rightPlayerId: '',
|
|
||||||
leftNameOverride: '',
|
|
||||||
rightNameOverride: '',
|
|
||||||
leftTeamOverride: '',
|
|
||||||
rightTeamOverride: '',
|
|
||||||
leftCountryOverride: '',
|
|
||||||
rightCountryOverride: '',
|
|
||||||
leftCharacter: '',
|
|
||||||
rightCharacter: '',
|
|
||||||
leftScore: 0,
|
|
||||||
rightScore: 0,
|
|
||||||
round: '',
|
|
||||||
game: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
|
|
||||||
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
|
|
||||||
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? 'scoreboard/main.html');
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
scoreboardSkin,
|
scoreboardSkin,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { commentaryReplicant, graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../../nodecg/browser/replicants';
|
||||||
|
import { defaultCommentary } from '../../../shared/domain/commentary';
|
||||||
|
import { defaultScoreboard } from '../../../shared/domain/scoreboard';
|
||||||
|
import type { Schemas } from '../../../types';
|
||||||
|
|
||||||
|
export const useScoreboardReplicatedState = (defaultSkin: string) => {
|
||||||
|
const players = computed<Schemas.Players>(() => playersReplicant?.data ?? {});
|
||||||
|
const scoreboard = computed<Schemas.Scoreboard>(() => scoreboardReplicant?.data ?? defaultScoreboard);
|
||||||
|
const scoreboardSkin = computed(() => graphicsSettingsReplicant?.data?.scoreboardSkin ?? defaultSkin);
|
||||||
|
|
||||||
|
return {
|
||||||
|
players,
|
||||||
|
scoreboard,
|
||||||
|
scoreboardSkin,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCommentaryReplicatedState = () => {
|
||||||
|
const commentary = computed<Schemas.Commentary>(() => commentaryReplicant?.data ?? defaultCommentary);
|
||||||
|
|
||||||
|
return {
|
||||||
|
commentary,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const sendNodecgMessage = <T>(messageName: string, payload: unknown): Promise<T> =>
|
||||||
|
new Promise((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 T);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sendNodecgCommand = (messageName: string, payload?: unknown): Promise<void> =>
|
||||||
|
sendNodecgMessage<void>(messageName, payload);
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { BUNDLE_NAME } from '../../shared/domain/packs/config';
|
||||||
|
import type {
|
||||||
|
PackDownloadState,
|
||||||
|
PackRegistry,
|
||||||
|
PackUpdateInfo,
|
||||||
|
} from '../../shared/domain/packs/types';
|
||||||
|
import { replicantNames } from '../replicantNames';
|
||||||
|
|
||||||
|
interface BrowserReplicant<T> {
|
||||||
|
value: T;
|
||||||
|
on(event: 'change', handler: (newVal: T, oldVal?: T) => void): void;
|
||||||
|
off(event: 'change', handler: (newVal: T, oldVal?: T) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackBrowserReplicants {
|
||||||
|
registryRep: BrowserReplicant<PackRegistry | null>;
|
||||||
|
installedRep: BrowserReplicant<string[]>;
|
||||||
|
statesRep: BrowserReplicant<Record<string, PackDownloadState>>;
|
||||||
|
updatesRep: BrowserReplicant<Record<string, PackUpdateInfo>>;
|
||||||
|
waitUntilReady: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPackBrowserReplicants = (): PackBrowserReplicants => {
|
||||||
|
const registryRep = NodeCG.Replicant<PackRegistry | null>(
|
||||||
|
replicantNames.packRegistry,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
{ defaultValue: null },
|
||||||
|
);
|
||||||
|
const installedRep = NodeCG.Replicant<string[]>(
|
||||||
|
replicantNames.installedPacks,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
{ defaultValue: [] },
|
||||||
|
);
|
||||||
|
const statesRep = NodeCG.Replicant<Record<string, PackDownloadState>>(
|
||||||
|
replicantNames.downloadStates,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
{ defaultValue: {} },
|
||||||
|
);
|
||||||
|
const updatesRep = NodeCG.Replicant<Record<string, PackUpdateInfo>>(
|
||||||
|
replicantNames.availableUpdates,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
{ defaultValue: {} },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
registryRep,
|
||||||
|
installedRep,
|
||||||
|
statesRep,
|
||||||
|
updatesRep,
|
||||||
|
waitUntilReady: () => NodeCG.waitForReplicants(registryRep, installedRep, statesRep, updatesRep),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useReplicant } from 'nodecg-vue-composable';
|
||||||
|
import { BUNDLE_NAME } from '../../shared/domain/packs/config';
|
||||||
|
import type { Schemas } from '../../types';
|
||||||
|
import { replicantNames } from '../replicantNames';
|
||||||
|
|
||||||
|
export const playersReplicant = useReplicant<Schemas.Players>(
|
||||||
|
replicantNames.players,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const scoreboardReplicant = useReplicant<Schemas.Scoreboard>(
|
||||||
|
replicantNames.scoreboard,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const graphicsSettingsReplicant = useReplicant<Schemas.GraphicsSettings>(
|
||||||
|
replicantNames.graphicsSettings,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const commentaryReplicant = useReplicant<Schemas.Commentary>(
|
||||||
|
replicantNames.commentary,
|
||||||
|
BUNDLE_NAME,
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { NodeCGServerAPI } from '../../types/index.js';
|
||||||
|
|
||||||
|
let currentNodecg: NodeCGServerAPI | null = null;
|
||||||
|
|
||||||
|
export function setNodecgContext(ctx: NodeCGServerAPI): void {
|
||||||
|
currentNodecg = ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodecgContext(): NodeCGServerAPI {
|
||||||
|
if (!currentNodecg) {
|
||||||
|
throw new Error('NodeCG context has not been initialized.');
|
||||||
|
}
|
||||||
|
return currentNodecg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nodecgContext = {
|
||||||
|
get nodecg(): NodeCGServerAPI {
|
||||||
|
return getNodecgContext();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nodecg = new Proxy({} as NodeCGServerAPI, {
|
||||||
|
get(_target, prop: string | symbol) {
|
||||||
|
return getNodecgContext()[prop as keyof NodeCGServerAPI];
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { getNodecgContext } from './context.js';
|
||||||
|
|
||||||
|
type HandledAcknowledgement = { handled: true };
|
||||||
|
type UnhandledAcknowledgement = ((error?: Error | null, ...args: unknown[]) => void) & {
|
||||||
|
handled: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Acknowledgement = HandledAcknowledgement | UnhandledAcknowledgement;
|
||||||
|
export type NodecgMessageHandler = (
|
||||||
|
payload: unknown,
|
||||||
|
ack?: Acknowledgement,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
|
export const reply = (
|
||||||
|
ack: Acknowledgement | undefined,
|
||||||
|
error: Error | null,
|
||||||
|
result?: unknown,
|
||||||
|
): void => {
|
||||||
|
if (ack && !ack.handled) {
|
||||||
|
ack(error ?? undefined, result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listenForMessage = (messageName: string, handler: NodecgMessageHandler): void => {
|
||||||
|
getNodecgContext().listenFor(messageName, handler);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type {
|
||||||
|
PackDownloadState,
|
||||||
|
PackRegistry,
|
||||||
|
PackUpdateInfo,
|
||||||
|
} from '../../shared/domain/packs/types.js';
|
||||||
|
import { replicantNames } from '../replicantNames.js';
|
||||||
|
import { getNodecgContext } from './context.js';
|
||||||
|
|
||||||
|
export const createPackExtensionReplicants = () => {
|
||||||
|
const nodecg = getNodecgContext();
|
||||||
|
|
||||||
|
return {
|
||||||
|
installedPacksRep: nodecg.Replicant<string[]>(replicantNames.installedPacks, {
|
||||||
|
defaultValue: [],
|
||||||
|
persistent: true,
|
||||||
|
}),
|
||||||
|
packRegistryRep: nodecg.Replicant<PackRegistry | null>(replicantNames.packRegistry, {
|
||||||
|
defaultValue: null,
|
||||||
|
persistent: true,
|
||||||
|
}),
|
||||||
|
downloadStatesRep: nodecg.Replicant<Record<string, PackDownloadState>>(
|
||||||
|
replicantNames.downloadStates,
|
||||||
|
{
|
||||||
|
defaultValue: {},
|
||||||
|
persistent: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
availableUpdatesRep: nodecg.Replicant<Record<string, PackUpdateInfo>>(
|
||||||
|
replicantNames.availableUpdates,
|
||||||
|
{
|
||||||
|
defaultValue: {},
|
||||||
|
persistent: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type NodeCG from 'nodecg/types';
|
||||||
|
import type { Schemas } from '../../types/index.js';
|
||||||
|
import { replicantNames } from '../replicantNames.js';
|
||||||
|
import { getNodecgContext } from './context.js';
|
||||||
|
|
||||||
|
type ServerReplicantWithDefault<T> = NodeCG.default.ServerReplicantWithSchemaDefault<T>;
|
||||||
|
type ServerReplicant<T> = NodeCG.default.ServerReplicant<T>;
|
||||||
|
|
||||||
|
export function getReplicantWithDefault<T>(name: string): ServerReplicantWithDefault<T> {
|
||||||
|
return getNodecgContext().Replicant<T>(name) as unknown as ServerReplicantWithDefault<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReplicant<T>(name: string): ServerReplicant<T> {
|
||||||
|
return getNodecgContext().Replicant<T>(name) as ServerReplicant<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const playersReplicant = (): ServerReplicantWithDefault<Schemas.Players> =>
|
||||||
|
getReplicantWithDefault<Schemas.Players>(replicantNames.players);
|
||||||
|
|
||||||
|
export const scoreboardReplicant = (): ServerReplicantWithDefault<Schemas.Scoreboard> =>
|
||||||
|
getReplicantWithDefault<Schemas.Scoreboard>(replicantNames.scoreboard);
|
||||||
|
|
||||||
|
export const commentaryReplicant = (): ServerReplicant<Schemas.Commentary> =>
|
||||||
|
getNodecgContext().Replicant<Schemas.Commentary>(replicantNames.commentary, {
|
||||||
|
defaultValue: {
|
||||||
|
leftCommentator: '',
|
||||||
|
leftCommentatorTwitter: '',
|
||||||
|
rightCommentator: '',
|
||||||
|
rightCommentatorTwitter: '',
|
||||||
|
},
|
||||||
|
persistent: false,
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const messageNames = {
|
||||||
|
packs: {
|
||||||
|
fetchRegistry: 'fetchPackRegistry',
|
||||||
|
download: 'downloadPack',
|
||||||
|
uninstall: 'uninstallPack',
|
||||||
|
update: 'updatePack',
|
||||||
|
readLocalManifest: 'readLocalManifest',
|
||||||
|
},
|
||||||
|
integrations: {
|
||||||
|
startgg: {
|
||||||
|
createOAuthSession: 'startgg:createOAuthSession',
|
||||||
|
getOAuthSessionStatus: 'startgg:getOAuthSessionStatus',
|
||||||
|
fetchRecentTournaments: 'startgg:fetchRecentTournaments',
|
||||||
|
fetchTournamentPlayers: 'startgg:fetchTournamentPlayers',
|
||||||
|
},
|
||||||
|
challonge: {
|
||||||
|
createOAuthSession: 'challonge:createOAuthSession',
|
||||||
|
getOAuthSessionStatus: 'challonge:getOAuthSessionStatus',
|
||||||
|
fetchRecentTournaments: 'challonge:fetchRecentTournaments',
|
||||||
|
fetchTournamentPlayers: 'challonge:fetchTournamentPlayers',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type MessageName =
|
||||||
|
| typeof messageNames.packs[keyof typeof messageNames.packs]
|
||||||
|
| typeof messageNames.integrations.startgg[keyof typeof messageNames.integrations.startgg]
|
||||||
|
| typeof messageNames.integrations.challonge[keyof typeof messageNames.integrations.challonge];
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export const replicantNames = {
|
||||||
|
scoreboard: 'scoreboard',
|
||||||
|
players: 'players',
|
||||||
|
commentary: 'commentary',
|
||||||
|
graphicsSettings: 'graphicsSettings',
|
||||||
|
installedPacks: 'installedPacks',
|
||||||
|
packRegistry: 'packRegistry',
|
||||||
|
downloadStates: 'downloadStates',
|
||||||
|
availableUpdates: 'availableUpdates',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ReplicantName = typeof replicantNames[keyof typeof replicantNames];
|
||||||
|
After Width: | Height: | Size: 448 KiB |
|
After Width: | Height: | Size: 409 KiB |
|
After Width: | Height: | Size: 619 KiB |
|
After Width: | Height: | Size: 456 KiB |
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 407 KiB |
|
After Width: | Height: | Size: 443 KiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 435 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 395 KiB |
|
After Width: | Height: | Size: 594 KiB |
|
After Width: | Height: | Size: 606 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 488 KiB |
|
After Width: | Height: | Size: 405 KiB |
|
After Width: | Height: | Size: 674 KiB |
|
After Width: | Height: | Size: 547 KiB |
|
After Width: | Height: | Size: 684 KiB |
|
After Width: | Height: | Size: 599 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 493 KiB |
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 480 KiB |