12 Commits

Author SHA1 Message Date
Pandipipas b32c0e4560 feat: implement replicant state synchronization for commentary, players, scoreboard, and graphics settings
- Added a new service for synchronizing state with replicants in `replicant-state-service.ts`.
- Refactored commentary store to utilize the new synchronization service.
- Created a new graphics settings store that syncs with replicants.
- Introduced a packs store for managing installed packs and their states.
- Updated players and scoreboard stores to use the new synchronization service.
- Created shared services for managing replicated state in graphics components.
- Refactored existing components to use the new shared services for replicant state.
- Added normalization and default values for commentary, graphics settings, players, and scoreboard.
- Improved type safety and organization in shared domain files for better maintainability.
2026-05-30 21:22:48 +02:00
Pandipipas 02a108f983 refactor: architecture base
- Moved NodeCG context management to a dedicated context module.
- Introduced message handling utilities for better message listening and sending.
- Updated startgg integration to use new message handling methods.
- Removed deprecated replicant utilities and replaced them with a new structure.
- Refactored replicant imports in graphics components to align with new structure.
- Added new pack-related types and schemas for better type safety.
- Cleaned up unused files and consolidated pack configuration into a single module.
- Updated TypeScript configurations to reflect new directory structure.
2026-05-23 21:52:07 +02:00
Pandipipas 225b2b36a2 feat: add architectural documentation for refactor; include audit, migration plan, rules, target architecture, and session handoff 2026-05-23 20:48:31 +02:00
Pandipipas 8c270feb5b feat: enhance pack management and character handling; implement automatic registry refresh and logo display updates 2026-05-22 21:19:45 +02:00
Pandipipas 618d18d8fb feat: update pack handling and character image paths; implement installed packs revision tracking 2026-05-21 23:59:22 +02:00
Pandipipas 0bc6f60b2c feat: update Gitea configuration for base URL and owner; add updateInfo to GameSelectOption interface 2026-05-21 23:06:54 +02:00
Pandipipas 88aeedb5ff feat: update character images for Tekken 8 and enhance pack management
- Updated character images for Tekken 8, including Jin, Jun, Kazuya, and others.
- Introduced a new pack configuration system to manage character packs from a Gitea instance.
- Added types for pack management, including PackCharacter, PackManifest, and PackRegistry.
- Implemented functions to register and unregister installed packs, allowing dynamic character loading.
- Enhanced the character image retrieval system to support both bundled and installed packs.
2026-05-21 17:59:13 +02:00
Pandipipas 04f2c2037a feat: add character images for Guilty Gear Strive and update fighting characters with DLC support 2026-05-20 16:34:37 +02:00
Pandipipas fd4201a882 fix: update translations for improved clarity and consistency in settings and about sections 2026-05-20 00:03:12 +02:00
Pandipipas 787de05034 feat: enhance settings view with integration options for start.gg and Challonge, add manual token dialogs, and improve keyboard shortcut management 2026-05-19 03:21:50 +02:00
Pandipipas 67d9d20b56 feat: enhance OAuth configuration to support proxy mode and update related logic 2026-05-18 21:47:06 +02:00
Pandipipas 79f6653d94 feat: update player source chips with icons and improve styling for better visual clarity 2026-05-18 00:29:31 +02:00
165 changed files with 4640 additions and 1823 deletions
+3
View File
@@ -136,9 +136,12 @@ dist
/dashboard/
/extension/
/graphics/
/nodecg/
/shared/domain/
/shared/dist/
# Local runtime database
/db/
*.sqlite3
/scoreko-electron-dev/
/packs/
+10 -16
View File
@@ -3,45 +3,39 @@
"type": "object",
"additionalProperties": false,
"properties": {
"exampleProperty": {
"type": "string"
"oauthProxyUrl": {
"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": {
"type": "string",
"default": "",
"description": "Client ID de tu OAuth app de start.gg"
"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)."
},
"startggClientSecret": {
"type": "string",
"default": "",
"description": "Client Secret de tu OAuth app de start.gg"
"description": "DEV ONLY: Client Secret de tu propia OAuth app de start.gg. NUNCA subas este valor a git."
},
"startggOAuthPort": {
"type": "integer",
"default": 34920,
"minimum": 1,
"maximum": 65535,
"description": "Puerto local para callback OAuth"
"description": "Puerto local para el servidor de callback OAuth de start.gg."
},
"challongeClientId": {
"type": "string",
"default": "",
"description": "Client ID de tu OAuth app de Challonge"
"description": "DEV ONLY: Client ID de tu propia OAuth app de Challonge. Si está presente junto a challongeClientSecret, activa el modo dev."
},
"challongeClientSecret": {
"type": "string",
"default": "",
"description": "Client Secret de tu OAuth app de Challonge"
"description": "DEV ONLY: Client Secret de tu propia OAuth app de Challonge. NUNCA subas este valor a git."
},
"challongeOAuthPort": {
"type": "integer",
"default": 34921,
"minimum": 1,
"maximum": 65535,
"description": "Puerto local para callback OAuth de Challonge"
"description": "Puerto local para el servidor de callback OAuth de Challonge."
}
},
"required": [
"exampleProperty"
]
}
}
+108
View File
@@ -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. |
+97
View File
@@ -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.
+187
View File
@@ -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. |
+56
View File
@@ -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.
+85
View File
@@ -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.
+70
View File
@@ -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.
+203
View File
@@ -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.
+1 -1
View File
@@ -16,7 +16,7 @@
"type": "module",
"scripts": {
"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",
"lint": "eslint",
"schema-types": "nodecg schema-types",
+18
View File
@@ -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": {}
}
+22
View File
@@ -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": {}
}
-14
View File
@@ -1,14 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"additionalProperties": false,
"properties": {
"exampleProperty": {
"type": "string",
"default": "exampleString"
}
},
"required": [
"exampleProperty"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "array",
"items": {
"type": "string"
},
"default": []
}
+50
View File
@@ -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
}
-17
View File
@@ -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);
-38
View File
@@ -2,35 +2,6 @@
import { ref } from 'vue';
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
"Complaining about Paul's damage",
'Nerfing Gigas',
@@ -38,15 +9,6 @@ const loadQuotes = [
'Sidestepping your electric',
'Punishing hellsweep with 1,1,2',
'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);
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard';
import { useScoreboardStore } from '../../stores/scoreboard';
const scoreboardStore = useScoreboardStore();
@@ -83,12 +83,12 @@ const updateRound = () => {
return;
}
if (customActive.value) {
scoreboardStore.scoreboard.round = customText.value.trim();
scoreboardStore.setRound(customText.value.trim());
return;
}
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);
@@ -245,4 +245,4 @@ onMounted(() => {
.bracket-panel__preview-text--custom {
color: var(--q-secondary, #26a69a);
}
</style>
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { stripTwitterPrefix } from '../../../shared/domain/commentary';
import { t } from '../i18n';
import { useCommentaryStore } from '../stores/commentary';
import { useCommentaryStore } from '../../stores/commentary';
const commentaryStore = useCommentaryStore();
@@ -17,25 +18,18 @@ const twitterRules = [
!val || TWITTER_VALID_CHARS.test(val) || t('commentaryTwitterInvalidChars'),
];
function stripAt(value: string): string {
return value.startsWith('@') ? value.slice(1) : value;
}
function handleLeftTwitterInput(value: string | number | null) {
commentaryStore.leftCommentatorTwitter = value ? stripAt(String(value)) : '';
commentaryStore.leftCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
}
function handleRightTwitterInput(value: string | number | null) {
commentaryStore.rightCommentatorTwitter = value ? stripAt(String(value)) : '';
commentaryStore.rightCommentatorTwitter = value ? stripTwitterPrefix(String(value)) : '';
}
// --- Clear ---
function clearAll() {
commentaryStore.leftCommentator = '';
commentaryStore.leftCommentatorTwitter = '';
commentaryStore.rightCommentator = '';
commentaryStore.rightCommentatorTwitter = '';
commentaryStore.clearCommentary();
}
const isAnythingFilled = computed(() =>
@@ -280,4 +274,4 @@ const rightHandlePreview = computed(() =>
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>
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { computed, inject } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { usePlayerSide } from '../composables/usePlayerSide';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { usePlayerSide } from '../composables/usePlayerSide';
import { t } from '../i18n';
import { useScoreboardStore } from '../../stores/scoreboard';
// ---------------------------------------------------------------------------
// Props
@@ -74,8 +74,7 @@ const character = computed({
? scoreboardStore.scoreboard.leftCharacter
: scoreboardStore.scoreboard.rightCharacter),
set: (v) => {
if (isLeft.value) scoreboardStore.scoreboard.leftCharacter = v;
else scoreboardStore.scoreboard.rightCharacter = v;
scoreboardStore.setSideCharacter(props.side, v);
},
});
@@ -140,6 +139,19 @@ const sideImageLabel = computed(() => t(isLeft.value ? 'scoreboardLeftImage' : '
<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>
@@ -372,6 +384,19 @@ const sideImageLabel = computed(() => t(isLeft.value ? 'scoreboardLeftImage' : '
<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>
@@ -481,6 +506,27 @@ const sideImageLabel = computed(() => t(isLeft.value ? 'scoreboardLeftImage' : '
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);
@@ -1,25 +1,67 @@
<script setup lang="ts">
import { inject } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
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 { gameInput, fightingGameOptions, onGameFilter } = inject(CHARACTER_GAME_KEY)!;
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.leftScore = Math.max(0, scoreboardStore.leftScore + delta);
scoreboardStore.adjustScore('left', delta);
};
const adjustRightScore = (delta: number) => {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta);
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
v-model="scoreboardStore.scoreboard.game"
:model-value="scoreboardStore.scoreboard.game"
v-model:input-value="gameInput"
:options="fightingGameOptions"
:label="t('scoreboardLabelGame')"
@@ -32,10 +74,59 @@ const adjustRightScore = (delta: number) => {
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">
@@ -101,8 +192,25 @@ const adjustRightScore = (delta: number) => {
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>
@@ -188,4 +296,13 @@ const adjustRightScore = (delta: number) => {
.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>
@@ -1,60 +1,92 @@
// 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 { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters';
import { useScoreboardStore } from '../stores/scoreboard';
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';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const ALL_FIGHTING_GAME_OPTIONS = [
'2XKO',
'FATAL FURY: City of the Wolves',
'Guilty Gear -Strive-',
'Invincible VS',
'Mortal Kombat 1',
'Street Fighter 6',
'TEKKEN 8',
'THE KING OF FIGHTERS XV',
].map((game) => ({ label: game, value: game }));
export type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
// ---------------------------------------------------------------------------
// Injection key (type-safe provide/inject)
// ---------------------------------------------------------------------------
// ── Types ─────────────────────────────────────────────────────────────────────
export type CharacterOption = FightingCharacterOption;
export type CharacterGameContext = ReturnType<typeof useCharacterGame>;
export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame');
// ---------------------------------------------------------------------------
// Composable
// ---------------------------------------------------------------------------
// ── Composable ────────────────────────────────────────────────────────────────
/**
* Manages game selection and character state for both sides.
* Must be called ONCE in the parent (ScoreboardPanel) and provided via
* CHARACTER_GAME_KEY so both PlayerSidePanel instances share the same state.
*/
export function useCharacterGame() {
const scoreboardStore = useScoreboardStore();
const packRegistry = usePackRegistry();
// ── Game selector state ───────────────────────────────────────────────────
// Game selector
const gameInput = ref('');
const fightingGameOptions = ref(ALL_FIGHTING_GAME_OPTIONS);
// Per-side character state
const characterOptions = computed(() => getCharactersByGame(scoreboardStore.scoreboard.game));
/**
* 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('');
// Remembers selected characters per game so swapping games restores them
const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({});
// Character images for preview
const leftCharacterImage = computed(() => {
const match = characterOptions.value.find(
(o) => o.value === scoreboardStore.scoreboard.leftCharacter,
@@ -69,20 +101,21 @@ export function useCharacterGame() {
return match?.image ?? '';
});
// ---------------------------------------------------------------------------
// Filter handlers
// ---------------------------------------------------------------------------
// ── Filter handlers ───────────────────────────────────────────────────────
const onGameFilter = (value: string, update: (fn: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
fightingGameOptions.value = needle
? ALL_FIGHTING_GAME_OPTIONS.filter((g) => g.label.toLowerCase().includes(needle))
: ALL_FIGHTING_GAME_OPTIONS;
? packRegistry.allGameOptions.value.filter((g) =>
g.label.toLowerCase().includes(needle),
)
: packRegistry.allGameOptions.value;
});
};
const makeCharacterFilter = (target: Ref<CharacterOption[]>) =>
const makeCharacterFilter =
(target: Ref<CharacterOption[]>) =>
(value: string, update: (fn: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
@@ -95,16 +128,14 @@ export function useCharacterGame() {
const onLeftCharacterFilter = makeCharacterFilter(leftCharacterOptions);
const onRightCharacterFilter = makeCharacterFilter(rightCharacterOptions);
// ---------------------------------------------------------------------------
// Watchers
// ---------------------------------------------------------------------------
// ── Watchers ──────────────────────────────────────────────────────────────
// Keep gameInput display value in sync
// Keep gameInput display value in sync with the store
watch(
() => scoreboardStore.scoreboard.game,
(value) => {
const match = ALL_FIGHTING_GAME_OPTIONS.find((o) => o.value === value);
gameInput.value = match?.label ?? '';
const match = fightingGameOptions.value.find((o) => o.value === value);
gameInput.value = match?.label ?? value;
},
{ immediate: true },
);
@@ -120,7 +151,14 @@ export function useCharacterGame() {
};
}
const options = getCharactersByGame(newGame);
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));
@@ -133,9 +171,8 @@ export function useCharacterGame() {
if (!allowed.has(nextLeft)) nextLeft = '';
if (!allowed.has(nextRight)) nextRight = '';
// Apply defaults only when neither side had a character yet
if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) {
const defaults = getDefaultCharactersByGame(newGame);
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 : '';
@@ -143,23 +180,22 @@ export function useCharacterGame() {
}
if (allowed.has(nextLeft)) {
scoreboardStore.scoreboard.leftCharacter = nextLeft;
scoreboardStore.setSideCharacter('left', nextLeft);
} else if (!allowed.has(scoreboardStore.scoreboard.leftCharacter)) {
scoreboardStore.scoreboard.leftCharacter = '';
scoreboardStore.setSideCharacter('left', '');
leftCharacterInput.value = '';
}
if (allowed.has(nextRight)) {
scoreboardStore.scoreboard.rightCharacter = nextRight;
scoreboardStore.setSideCharacter('right', nextRight);
} else if (!allowed.has(scoreboardStore.scoreboard.rightCharacter)) {
scoreboardStore.scoreboard.rightCharacter = '';
scoreboardStore.setSideCharacter('right', '');
rightCharacterInput.value = '';
}
},
{ immediate: true },
);
// Keep left character display input and charactersByGame cache in sync
watch(
() => scoreboardStore.scoreboard.leftCharacter,
(value) => {
@@ -176,7 +212,6 @@ export function useCharacterGame() {
{ immediate: true },
);
// Keep right character display input and charactersByGame cache in sync
watch(
() => scoreboardStore.scoreboard.rightCharacter,
(value) => {
@@ -193,16 +228,53 @@ export function useCharacterGame() {
{ 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,
onGameFilter,
onLeftCharacterFilter,
onRightCharacterFilter,
};
@@ -1,5 +1,5 @@
import { computed, ref, watch } from 'vue';
import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
import { getCountryLabel, getCountryOptions } from '../../../shared/domain/players/countries';
import { locale } from '../i18n';
/**
@@ -1,4 +1,5 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { sendIntegrationMessage } from '../../services/integration-message-service';
// ─── Tipos ─────────────────────────────────────────────────────────────────────
@@ -65,19 +66,6 @@ export interface UseIntegrationOptions {
playersStore: PlayersStore;
}
// ─── Utilidad para mensajes NodeCG ─────────────────────────────────────────────
const sendNodeCGMessage = <T>(messageName: string, payload: unknown): Promise<T> =>
new Promise((resolve, reject) => {
nodecg.sendMessage(messageName, payload, (error: unknown, response: unknown) => {
if (error) {
reject(new Error(String(error)));
return;
}
resolve(response as T);
});
});
// ─── Composable ────────────────────────────────────────────────────────────────
export function useIntegration(options: UseIntegrationOptions) {
@@ -165,8 +153,9 @@ export function useIntegration(options: UseIntegrationOptions) {
tournamentsError.value = '';
loadingTournaments.value = true;
try {
const tournaments = await sendNodeCGMessage<IntegrationTournament[]>(
`${messagePrefix}:fetchRecentTournaments`,
const tournaments = await sendIntegrationMessage<IntegrationTournament[]>(
messagePrefix,
'fetchRecentTournaments',
{ token: currentToken },
);
hasValidatedToken.value = true;
@@ -204,8 +193,9 @@ export function useIntegration(options: UseIntegrationOptions) {
players.value = [];
try {
const importedPlayers = await sendNodeCGMessage<IntegrationPlayer[]>(
`${messagePrefix}:fetchTournamentPlayers`,
const importedPlayers = await sendIntegrationMessage<IntegrationPlayer[]>(
messagePrefix,
'fetchTournamentPlayers',
{ token: token.value.trim(), slug: tournament.slug },
);
players.value = importedPlayers;
@@ -325,8 +315,9 @@ export function useIntegration(options: UseIntegrationOptions) {
if (!oauthSessionId.value) return;
try {
const status = await sendNodeCGMessage<OAuthStatusResponse>(
`${messagePrefix}:getOAuthSessionStatus`,
const status = await sendIntegrationMessage<OAuthStatusResponse>(
messagePrefix,
'getOAuthSessionStatus',
{ sessionId: oauthSessionId.value },
);
@@ -362,8 +353,9 @@ export function useIntegration(options: UseIntegrationOptions) {
stopPolling();
try {
const session = await sendNodeCGMessage<OAuthSessionResponse>(
`${messagePrefix}:createOAuthSession`,
const session = await sendIntegrationMessage<OAuthSessionResponse>(
messagePrefix,
'createOAuthSession',
{},
);
oauthSessionId.value = session.sessionId;
@@ -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,
};
}
@@ -1,7 +1,8 @@
import { computed, ref, watch, watchEffect } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { usePlayersStore } from '../stores/players';
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';
@@ -16,34 +17,6 @@ export const CUSTOM_RIGHT_PLAYER_ID = '__custom_right_player__';
// Pure helpers (no Vue reactivity)
// ---------------------------------------------------------------------------
const normalizeName = (value: string) => value.trim().toLowerCase();
/**
* Generates a unique slug-based player ID that does not collide with
* existing player keys in the store.
*/
const createPlayerId = (name: string, players: Schemas.Players): string => {
const base = name
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[^\w\s-]/g, '')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-') || 'player';
let index = 1;
let candidate = base;
while (players[candidate]) {
index += 1;
candidate = `${base}-${index}`;
}
return candidate;
};
// ---------------------------------------------------------------------------
// Composable
// ---------------------------------------------------------------------------
/**
* Encapsulates all reactive state and handlers for one side of the scoreboard
* (left or right). Call once per side inside the corresponding component.
@@ -63,32 +36,28 @@ export function usePlayerSide(side: 'left' | 'right') {
const playerId = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftPlayerId : scoreboardStore.scoreboard.rightPlayerId),
set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftPlayerId = v;
else scoreboardStore.scoreboard.rightPlayerId = v;
scoreboardStore.setSidePlayerId(side, v);
},
});
const nameOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftNameOverride : scoreboardStore.scoreboard.rightNameOverride),
set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftNameOverride = v;
else scoreboardStore.scoreboard.rightNameOverride = v;
scoreboardStore.setSideNameOverride(side, v);
},
});
const teamOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftTeamOverride : scoreboardStore.scoreboard.rightTeamOverride),
set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftTeamOverride = v;
else scoreboardStore.scoreboard.rightTeamOverride = v;
scoreboardStore.setSideTeamOverride(side, v);
},
});
const countryOverride = computed({
get: () => (isLeft ? scoreboardStore.scoreboard.leftCountryOverride : scoreboardStore.scoreboard.rightCountryOverride),
set: (v) => {
if (isLeft) scoreboardStore.scoreboard.leftCountryOverride = v;
else scoreboardStore.scoreboard.rightCountryOverride = v;
scoreboardStore.setSideCountryOverride(side, v);
},
});
@@ -145,10 +114,10 @@ export function usePlayerSide(side: 'left' | 'right') {
};
const playerExistsByGamertag = (name: string): boolean => {
const normalized = normalizeName(name);
const normalized = normalizePlayerName(name);
return Boolean(normalized)
&& Object.values(playersStore.players).some(
(p) => normalizeName(p.gamertag || '') === normalized,
(p) => normalizePlayerName(p.gamertag || '') === normalized,
);
};
@@ -343,4 +312,4 @@ export function usePlayerSide(side: 'left' | 'right') {
saveCountryChange,
onCountryFilter,
};
}
}
+130 -83
View File
@@ -24,6 +24,14 @@ type Translations = {
settingsShortcutRightDecrementHint: string;
settingsShortcutReset: string;
settingsShortcutRecordingHint: string;
settingsShortcutConflictWarning: string;
settingsShortcutStartRecording: string;
settingsShortcutStopRecording: string;
settingsShortcutResetSingle: string;
settingsIntegrationsTitle: string;
settingsIntegrationsDescription: string;
settingsDisconnect: string;
settingsNotConnected: string;
languageEnglish: string;
languageSpanish: string;
scoreboardUnassigned: string;
@@ -53,6 +61,8 @@ type Translations = {
aboutElectronNote: string;
aboutUnknownReleaseError: string;
aboutGitHubStatusError: string;
aboutChangelog: string;
aboutTechStackTitle: string;
graphicsTitle: string;
graphicsDescription: string;
graphicsNoConfigured: string;
@@ -61,10 +71,16 @@ type Translations = {
graphicsScoreboard: string;
graphicsCommentary: string;
graphicsSkinLabel: string;
graphicsCopied: string;
graphicsOpenBrowser: string;
commentaryTitle: string;
commentaryCommentator1: string;
commentaryCommentator2: string;
commentaryTwitterText: string;
commentaryTwitterMaxLength: string;
commentaryTwitterInvalidChars: string;
commentarySwap: string;
commentaryClear: string;
bracketTitle: string;
bracketStage: string;
bracketSide: string;
@@ -85,18 +101,8 @@ type Translations = {
playersSearchPlaceholder: string;
playersImport: string;
playersExport: string;
commentaryTwitterMaxLength: string;
commentaryTwitterInvalidChars: string;
commentarySwap: string;
commentaryClear: string;
aboutChangelog : string;
aboutTechStackTitle : string;
settingsShortcutConflictWarning : string;
settingsShortcutStartRecording: string;
settingsShortcutStopRecording: string;
settingsShortcutResetSingle: string;
graphicsCopied : string;
graphicsOpenBrowser : string;
playersConnectInSettings: string;
playersConnectInSettingsSuffix: string;
};
const STORAGE_KEY = 'scoreko-dev.language';
@@ -108,28 +114,42 @@ const messages: Record<Locale, Translations> = {
menuGraphics: 'Graphics',
menuSettings: 'Settings',
menuAbout: 'About',
// ── Settings ────────────────────────────────────────────────────────────
settingsTitle: 'Settings',
settingsDescription: 'Dashboard and bundle configuration.',
settingsDescription: 'Dashboard and bundle settings.',
settingsLanguageLabel: 'Language',
settingsLanguageHint: 'Choose the dashboard language.',
settingsShortcutTitle: 'Keyboard shortcuts',
settingsShortcutDescription: 'Configure quick keys to update the score for each side.',
settingsShortcutDescription: 'Configure keyboard shortcuts to update each sides score.',
settingsShortcutLeftIncrementLabel: 'P1 score +1',
settingsShortcutLeftIncrementHint: 'Increases left player score by one.',
settingsShortcutLeftIncrementHint: 'Increases the left players score by one.',
settingsShortcutLeftDecrementLabel: 'P1 score -1',
settingsShortcutLeftDecrementHint: 'Decreases left player score by one.',
settingsShortcutLeftDecrementHint: 'Decreases the left players score by one.',
settingsShortcutRightIncrementLabel: 'P2 score +1',
settingsShortcutRightIncrementHint: 'Increases right player score by one.',
settingsShortcutRightIncrementHint: 'Increases the right players score by one.',
settingsShortcutRightDecrementLabel: 'P2 score -1',
settingsShortcutRightDecrementHint: 'Decreases right player score by one.',
settingsShortcutRightDecrementHint: 'Decreases the right players score by one.',
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',
languageSpanish: 'Spanish',
// ── Scoreboard ───────────────────────────────────────────────────────────
scoreboardUnassigned: '(Unassigned)',
scoreboardLeft: 'Left',
scoreboardRight: 'Right',
scoreboardPreview: 'preview',
scoreboardPreview: 'Preview',
scoreboardLeftImage: 'Left image',
scoreboardRightImage: 'Right image',
scoreboardLabelCharacter: 'Character',
@@ -137,11 +157,13 @@ const messages: Record<Locale, Translations> = {
scoreboardLabelTeam: 'Team',
scoreboardLabelCountry: 'Country',
scoreboardLabelGame: 'Game',
// ── About ────────────────────────────────────────────────────────────────
aboutTitle: 'About',
aboutVersion: 'Version',
aboutDescription: 'Dashboard for producing fighting game overlays using NodeCG, Vue, and Quasar.',
aboutFrameworkNodeCG: 'Framework NodeCG',
aboutCollaboratorsTitle: 'Collaborators and acknowledgments',
aboutDescription: 'Dashboard for producing fighting game overlays with NodeCG, Vue, and Quasar.',
aboutFrameworkNodeCG: 'NodeCG framework',
aboutCollaboratorsTitle: 'Contributors and acknowledgments',
aboutUpdateSystemTitle: 'Update system (GitHub Releases)',
aboutUpdateSystemDescription: 'This check fetches the latest release from the repository and compares it with the current version.',
aboutCheckUpdates: 'Check for updates',
@@ -150,33 +172,49 @@ const messages: Record<Locale, Translations> = {
aboutUpdateAvailable: 'A newer version is available.',
aboutUpToDate: 'Your version is up to date with the latest 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 Electrons main process and publish signed artifacts per platform.',
aboutUnknownReleaseError: 'Unknown error while checking releases.',
aboutGitHubStatusError: 'GitHub responded with status',
aboutChangelog: 'Changelog',
aboutTechStackTitle: 'Tech stack',
// ── 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.',
graphicsCopyUrl: 'Copy URL',
graphicsDragObs: 'Drag into OBS',
graphicsScoreboard: 'Scoreboard',
graphicsCommentary: 'Commentary',
graphicsSkinLabel: 'Skin',
commentaryTitle: 'Commentary',
graphicsCommentary: 'Commentators',
graphicsSkinLabel: 'Theme',
graphicsCopied: 'URL copied to clipboard',
graphicsOpenBrowser: 'Open in browser',
// ── Commentary ───────────────────────────────────────────────────────────
commentaryTitle: 'Commentators',
commentaryCommentator1: 'Commentator #1',
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',
bracketStage: 'Stage',
bracketSide: 'Bracket side',
bracketCustomProgress: 'Custom progress',
bracketPreview: 'Preview',
// ── Players ──────────────────────────────────────────────────────────────
playersLabelTeam: 'Team',
playersLabelCountry: 'Country',
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',
playersConnected: 'Connected',
playersUsePersonalApi: 'Use personal API',
playersUsePersonalApi: 'Use personal token',
playersTournament: 'Tournament',
playersImportPlayers: 'Import players',
playersChallongeHelp: 'Connect with OAuth or paste your personal token to load your Challonge tournaments and import participants.',
@@ -185,43 +223,48 @@ const messages: Record<Locale, Translations> = {
playersSearchPlaceholder: 'Search...',
playersImport: 'Import',
playersExport: 'Export',
commentaryTwitterMaxLength: 'Twitter character limit exceeded',
commentaryTwitterInvalidChars: 'Invalid characters in Twitter text',
commentarySwap: 'Swap commentators',
commentaryClear: 'Clear commentary',
aboutChangelog: 'Changelog',
aboutTechStackTitle: 'Tech stack',
settingsShortcutConflictWarning: 'This shortcut is already assigned to',
settingsShortcutStartRecording: 'Start recording shortcut',
settingsShortcutStopRecording: 'Stop recording shortcut',
settingsShortcutResetSingle: 'Reset single player score shortcut',
graphicsCopied: 'URL copied to clipboard',
graphicsOpenBrowser: 'Open in browser',
playersConnectInSettings: 'Connect your account in',
playersConnectInSettingsSuffix: 'to import players from tournaments.',
},
es: {
menuDashboard: 'Panel',
menuPlayers: 'Jugadores',
menuGraphics: 'Gráficos',
menuSettings: 'Configuración',
menuAbout: 'Acerca de',
// ── Settings ────────────────────────────────────────────────────────────
settingsTitle: 'Configuración',
settingsDescription: 'Configuración del dashboard y del bundle.',
settingsDescription: 'Configuración del panel y del bundle.',
settingsLanguageLabel: 'Idioma',
settingsLanguageHint: 'Selecciona el idioma del dashboard.',
settingsShortcutTitle: 'Atajos de teclado',
settingsShortcutDescription: 'Configura teclas rápidas para actualizar el score de cada lado.',
settingsShortcutLeftIncrementLabel: 'Score P1 +1',
settingsShortcutLeftIncrementHint: 'Incrementa en uno el score del jugador izquierdo.',
settingsShortcutLeftDecrementLabel: 'Score P1 -1',
settingsShortcutLeftDecrementHint: 'Reduce en uno el score del jugador izquierdo.',
settingsShortcutRightIncrementLabel: 'Score P2 +1',
settingsShortcutRightIncrementHint: 'Incrementa en uno el score del jugador derecho.',
settingsShortcutRightDecrementLabel: 'Score P2 -1',
settingsShortcutRightDecrementHint: 'Reduce en uno el score del jugador derecho.',
settingsShortcutDescription: 'Configura atajos para actualizar el marcador de cada lado.',
settingsShortcutLeftIncrementLabel: 'Marcador P1 +1',
settingsShortcutLeftIncrementHint: 'Incrementa en uno el marcador del jugador izquierdo.',
settingsShortcutLeftDecrementLabel: 'Marcador P1 -1',
settingsShortcutLeftDecrementHint: 'Reduce en uno el marcador del jugador izquierdo.',
settingsShortcutRightIncrementLabel: 'Marcador P2 +1',
settingsShortcutRightIncrementHint: 'Incrementa en uno el marcador del jugador derecho.',
settingsShortcutRightDecrementLabel: 'Marcador P2 -1',
settingsShortcutRightDecrementHint: 'Reduce en uno el marcador del jugador derecho.',
settingsShortcutReset: 'Restablecer atajos',
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',
languageSpanish: 'Castellano',
languageSpanish: 'Español',
// ── Scoreboard ───────────────────────────────────────────────────────────
scoreboardUnassigned: '(Sin asignar)',
scoreboardLeft: 'Izquierda',
scoreboardRight: 'Derecha',
@@ -233,43 +276,61 @@ const messages: Record<Locale, Translations> = {
scoreboardLabelTeam: 'Equipo',
scoreboardLabelCountry: 'País',
scoreboardLabelGame: 'Juego',
// ── About ────────────────────────────────────────────────────────────────
aboutTitle: 'Acerca de',
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',
aboutCollaboratorsTitle: 'Colaboradores y agradecimientos',
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',
aboutLatestRelease: 'Última release',
aboutLatestRelease: 'Última versión',
aboutPublished: 'Publicado',
aboutUpdateAvailable: 'Hay una versión más nueva disponible.',
aboutUpToDate: 'Tu versión está actualizada con la última release.',
aboutViewRelease: 'Ver release',
aboutUpToDate: 'Tu versión está actualizada con la última versión.',
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.',
aboutUnknownReleaseError: 'Error desconocido al consultar releases.',
aboutGitHubStatusError: 'GitHub respondió con estado',
aboutChangelog: 'Registro de cambios',
aboutTechStackTitle: 'Stack tecnológico',
// ── Graphics ─────────────────────────────────────────────────────────────
graphicsTitle: 'Gráficos',
graphicsDescription: 'Controles y estado de los gráficos del bundle.',
graphicsNoConfigured: 'No hay gráficos configurados en este bundle.',
graphicsCopyUrl: 'Copiar URL',
graphicsDragObs: 'Arrastrar a OBS',
graphicsScoreboard: 'Scoreboard',
graphicsCommentary: 'Comentario',
graphicsSkinLabel: 'Skin',
commentaryTitle: 'Comentario',
graphicsScoreboard: 'Marcador',
graphicsCommentary: 'Comentaristas',
graphicsSkinLabel: 'Tema',
graphicsCopied: 'URL copiada al portapapeles',
graphicsOpenBrowser: 'Abrir en el navegador',
// ── Commentary ───────────────────────────────────────────────────────────
commentaryTitle: 'Comentaristas',
commentaryCommentator1: 'Comentarista #1',
commentaryCommentator2: 'Comentarista #2',
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',
bracketSide: 'Lado del bracket',
bracketSide: 'Lado de la llave',
bracketCustomProgress: 'Progreso personalizado',
bracketPreview: 'Vista previa',
// ── Players ──────────────────────────────────────────────────────────────
playersLabelTeam: 'Equipo',
playersLabelCountry: 'País',
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',
playersConnected: 'Conectado',
playersUsePersonalApi: 'Usar API personal',
@@ -281,28 +342,15 @@ const messages: Record<Locale, Translations> = {
playersSearchPlaceholder: 'Buscar...',
playersImport: 'Importar',
playersExport: 'Exportar',
commentaryTwitterMaxLength: 'Se excedió el límite de caracteres de Twitter',
commentaryTwitterInvalidChars: 'Caracteres inválidos en el texto de Twitter',
commentarySwap: 'Intercambiar comentaristas',
commentaryClear: 'Limpiar comentario',
aboutChangelog: 'Changelog',
aboutTechStackTitle: 'Tech stack',
settingsShortcutConflictWarning: 'This shortcut is already assigned to',
settingsShortcutStartRecording: 'Start recording shortcut',
settingsShortcutStopRecording: 'Stop recording shortcut',
settingsShortcutResetSingle: 'Reset single player score shortcut',
graphicsCopied: 'URL copiada al portapapeles',
graphicsOpenBrowser: 'Abrir en el navegador',
playersConnectInSettings: 'Conecta tu cuenta en',
playersConnectInSettingsSuffix: 'para importar jugadores desde torneos.',
},
};
const normalizeLocale = (value: unknown): Locale => (value === 'es' ? 'es' : 'en');
const getStoredLocale = (): Locale => {
if (typeof window === 'undefined') {
return 'en';
}
if (typeof window === 'undefined') return 'en';
return normalizeLocale(localStorage.getItem(STORAGE_KEY));
};
@@ -310,7 +358,6 @@ export const locale = ref<Locale>(getStoredLocale());
export const setLocale = (value: Locale) => {
locale.value = normalizeLocale(value);
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, locale.value);
}
+316 -80
View File
@@ -1,67 +1,87 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { t } from './i18n';
import { useScoreboardStore } from './stores/scoreboard';
import { isShortcutMatch, useShortcutSettingsStore } from './stores/shortcut-settings';
import { useScoreboardStore } from '../stores/scoreboard';
import { isShortcutMatch, useShortcutSettingsStore } from '../stores/shortcut-settings';
const menuItems = computed(() => [
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' },
{ label: t('menuPlayers'), to: '/players', icon: 'groups' },
{ label: t('menuGraphics'), to: '/graphics', icon: 'collections' },
// ── Sidebar collapse ──────────────────────────────────────────────────────────
const LS_KEY = 'sidebar_collapsed';
const isCollapsed = ref(localStorage.getItem(LS_KEY) === 'true');
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('menuAbout'), to: '/about', icon: 'info' },
{ label: t('menuAbout'), to: '/about', icon: 'info' },
]);
const logoUrl = new URL('./image.png', import.meta.url).href;
const scoreboardStore = useScoreboardStore();
// ── Online / Offline ──────────────────────────────────────────────────────────
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 isEditableTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) {
return false;
}
return target.isContentEditable
|| ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
|| Boolean(target.closest('[contenteditable="true"]'));
if (!(target instanceof HTMLElement)) return false;
return (
target.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) ||
Boolean(target.closest('[contenteditable="true"]'))
);
};
const onShortcutPress = (event: KeyboardEvent) => {
if (isEditableTarget(event.target) || document.body.dataset.shortcutRecording === 'true') {
return;
}
if (isEditableTarget(event.target) || document.body.dataset.shortcutRecording === 'true') return;
const { shortcuts } = shortcutSettingsStore;
if (isShortcutMatch(event, shortcuts.leftIncrement)) {
scoreboardStore.leftScore += 1;
event.preventDefault();
return;
}
if (isShortcutMatch(event, shortcuts.leftDecrement)) {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore - 1);
event.preventDefault();
return;
}
if (isShortcutMatch(event, shortcuts.rightIncrement)) {
scoreboardStore.rightScore += 1;
event.preventDefault();
return;
}
if (isShortcutMatch(event, shortcuts.rightDecrement)) {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore - 1);
event.preventDefault();
}
if (isShortcutMatch(event, shortcuts.leftIncrement)) { scoreboardStore.leftScore += 1; event.preventDefault(); return; }
if (isShortcutMatch(event, shortcuts.leftDecrement)) { scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore - 1); event.preventDefault(); return; }
if (isShortcutMatch(event, shortcuts.rightIncrement)) { scoreboardStore.rightScore += 1; event.preventDefault(); return; }
if (isShortcutMatch(event, shortcuts.rightDecrement)) { scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore - 1); event.preventDefault(); }
};
onMounted(() => {
window.addEventListener('keydown', onShortcutPress);
window.addEventListener('online', onNetworkOnline);
window.addEventListener('offline', onNetworkOffline);
pingInterval = setInterval(checkOnline, 15_000);
});
onUnmounted(() => {
window.removeEventListener('keydown', onShortcutPress);
window.removeEventListener('online', onNetworkOnline);
window.removeEventListener('offline', onNetworkOffline);
if (pingInterval) clearInterval(pingInterval);
});
</script>
@@ -71,49 +91,117 @@ onUnmounted(() => {
show-if-above
side="left"
bordered
:width="220"
:width="drawerWidth"
class="sidebar-drawer"
>
<div class="sidebar-header q-pa-md">
<div class="row items-center no-wrap">
<img
:src="logoUrl"
alt="Logo"
class="sidebar-logo"
>
<div class="q-ml-sm">
<div class="text-subtitle1 text-weight-bold">
Scoreko-dev
</div>
<div class="text-caption">
<span class="by-label">by</span> <a
class="by-link"
href="https://github.com/Pandipipas"
target="_blank"
rel="noopener"
>Pandipipas</a>
</div>
<!-- Header -->
<div class="sidebar-header" :class="{ 'is-collapsed': isCollapsed }">
<img :src="logoUrl" alt="Logo" class="sidebar-logo">
<Transition name="slide-fade">
<div v-if="!isCollapsed" class="sidebar-title">
<span class="title-text">Scoreko-dev</span>
<span v-if="appVersion" class="title-version">v{{ appVersion }}</span>
</div>
</div>
</Transition>
<!-- Chevron siempre visible, arriba a la derecha -->
<QBtn
flat
round
dense
size="sm"
:icon="isCollapsed ? 'chevron_right' : 'chevron_left'"
class="collapse-btn"
@click="toggleCollapse"
/>
</div>
<QSeparator 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
v-for="item in menuItems"
v-for="item in mainItems"
:key="item.to"
clickable
:to="item.to"
exact
active-class="sidebar-item-active"
:class="{ 'nav-item-collapsed': isCollapsed }"
>
<QItemSection avatar>
<QIcon :name="item.icon" />
<QIcon :name="item.icon" size="sm" />
</QItemSection>
<QItemSection>
<QItemSection v-if="!isCollapsed">
<QItemLabel>{{ item.label }}</QItemLabel>
</QItemSection>
<QTooltip
v-if="isCollapsed"
anchor="center right"
self="center left"
:offset="[10, 0]"
>
{{ item.label }}
</QTooltip>
</QItem>
</QList>
<!-- Sección CONFIG -->
<div class="section-sep" :class="{ 'is-collapsed': isCollapsed }">
<span v-if="!isCollapsed" class="section-label">CONFIG</span>
</div>
<QList padding>
<QItem
v-for="item in configItems"
:key="item.to"
clickable
:to="item.to"
exact
active-class="sidebar-item-active"
:class="{ 'nav-item-collapsed': isCollapsed }"
>
<QItemSection avatar>
<QIcon :name="item.icon" size="sm" />
</QItemSection>
<QItemSection v-if="!isCollapsed">
<QItemLabel>{{ item.label }}</QItemLabel>
</QItemSection>
<QTooltip
v-if="isCollapsed"
anchor="center right"
self="center left"
:offset="[10, 0]"
>
{{ item.label }}
</QTooltip>
</QItem>
</QList>
<!-- Footer: Online / Offline -->
<div class="sidebar-footer" :class="{ 'is-collapsed': isCollapsed }">
<div class="online-row">
<span class="online-dot" :class="isOnline ? 'dot-online' : 'dot-offline'" />
<Transition name="slide-fade">
<span v-if="!isCollapsed" class="online-label">
{{ isOnline ? 'Online' : 'Offline' }}
</span>
</Transition>
<QTooltip
v-if="isCollapsed"
anchor="center right"
self="center left"
:offset="[10, 0]"
>
{{ isOnline ? 'Online' : 'Offline' }}
</QTooltip>
</div>
</div>
</QDrawer>
<QPageContainer>
@@ -123,28 +211,176 @@ onUnmounted(() => {
</template>
<style scoped>
/* ── Drawer shell ─────────────────────────────────────────────────────────── */
.sidebar-drawer :deep(.q-drawer__content) {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* ── 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 {
width: 40px;
height: 40px;
width: 36px;
height: 36px;
object-fit: contain;
flex-shrink: 0;
}
.by-label {
font-size: 0.75rem;
.sidebar-title {
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 {
font-size: 0.75rem;
color: #f50a64;
text-decoration: none;
/* ── Collapse button ──────────────────────────────────────────────────────── */
.collapse-btn {
flex-shrink: 0;
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 {
text-decoration: underline;
/* ── Section separators ───────────────────────────────────────────────────── */
.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>
@@ -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,
};
});
+7 -9
View File
@@ -2,7 +2,7 @@
import { useHead } from '@unhead/vue';
import { computed, ref, watch } from 'vue';
import bundlePackage from '../../../../package.json';
import { graphicsSettingsReplicant } from '../../../browser_shared/replicants';
import { useGraphicsSettingsStore } from '../../stores/graphics-settings';
import { t } from '../i18n';
defineOptions({ name: 'GraphicsView' });
@@ -24,6 +24,7 @@ type GraphicCard = {
useHead(() => ({ title: t('graphicsTitle') }));
const graphicsSettingsStore = useGraphicsSettingsStore();
const graphics = computed<GraphicConfig[]>(() => bundlePackage.nodecg?.graphics ?? []);
const baseUrl = computed(() => {
@@ -60,7 +61,7 @@ const commentaryGraphic = computed(() =>
const selectedScoreboardSkin = ref<string>('');
watch(
[scoreboardGraphics, () => graphicsSettingsReplicant?.data?.scoreboardSkin],
[scoreboardGraphics, () => graphicsSettingsStore.settings.scoreboardSkin],
([availableSkins, replicatedSkin]) => {
if (availableSkins.length === 0) {
selectedScoreboardSkin.value = '';
@@ -87,18 +88,15 @@ watch(
watch(
selectedScoreboardSkin,
(value) => {
if (!value || !graphicsSettingsReplicant) {
if (!value) {
return;
}
if (graphicsSettingsReplicant.data?.scoreboardSkin === value) {
if (graphicsSettingsStore.settings.scoreboardSkin === value) {
return;
}
graphicsSettingsReplicant.data = {
scoreboardSkin: value,
};
graphicsSettingsReplicant.save();
graphicsSettingsStore.setScoreboardSkin(value);
},
{ immediate: true },
);
@@ -262,4 +260,4 @@ const onDragStart = (event: DragEvent, graphic: GraphicConfig) => {
</div>
</div>
</QPage>
</template>
</template>
+187 -379
View File
@@ -2,11 +2,11 @@
import { useHead } from '@unhead/vue';
import { useQuasar, type QTableColumn } from 'quasar';
import { computed, reactive, ref, watch } from 'vue';
import { getCountryLabel, getCountryOptions } from '../../../shared/countries';
import { getCountryLabel, getCountryOptions } from '../../../shared/domain/players/countries';
import type { Schemas } from '../../../types';
import { useIntegration } from '../composables/useIntegration';
import { locale, t } from '../i18n';
import { usePlayersStore } from '../stores/players';
import { usePlayersStore } from '../../stores/players';
defineOptions({ name: 'PlayersView' });
@@ -35,7 +35,7 @@ const playersStore = usePlayersStore();
const $q = useQuasar();
const rows = computed<PlayerRow[]>(() => playersStore.rows);
// ─── Integraciones ─────────────────────────────────────────────────────────────
// ─── Integraciones (solo se usa para importar torneos) ─────────────────────────
const startgg = useIntegration({
messagePrefix: 'startgg',
@@ -57,7 +57,6 @@ const challonge = useIntegration({
playersStore,
});
// Notifica errores de apertura del diálogo de importación (sustituye window.alert)
watch(() => startgg.importDialogError, (msg) => {
if (msg) $q.notify({ type: 'negative', message: msg });
});
@@ -83,12 +82,6 @@ const formatExpiresAt = (ts: number): string =>
year: 'numeric',
});
// ─── Label de conexión de Challonge ───────────────────────────────────────────
const challongeConnectionLabel = computed(() =>
challonge.hasValidatedToken ? t('playersConnected') : 'Token set',
);
// ─── Tabla de jugadores ────────────────────────────────────────────────────────
const filter = ref('');
@@ -152,9 +145,13 @@ const openCreateDialog = () => {
const openEditDialog = (row: PlayerRow) => {
editingId.value = row.id;
// CORRECCIÓN: evitar el patrón `void id` usando un alias _id
const { id: _id, ...playerData } = row;
Object.assign(form, playerData);
Object.assign(form, {
gamertag: row.gamertag,
name: row.name,
country: row.country,
team: row.team,
twitter: row.twitter,
});
isDialogOpen.value = true;
};
@@ -169,34 +166,6 @@ const deletePlayer = (row: PlayerRow) => {
}
};
// ─── 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;
};
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;
};
// ─── Exportar / importar JSON ──────────────────────────────────────────────────
const fileInput = ref<HTMLInputElement | null>(null);
@@ -232,6 +201,8 @@ const handleImport = async (event: Event) => {
<template>
<QPage class="q-pa-lg players-page">
<!-- Cabecera -->
<div class="row items-center q-mb-md">
<div class="text-h5 text-weight-medium">
{{ t('menuPlayers') }}
@@ -248,7 +219,9 @@ const handleImport = async (event: Event) => {
</div>
<div class="players-content row q-col-gutter-md">
<div class="col-12">
<!-- Columna principal: tabla -->
<div class="col-12 col-lg-8 players-main-column">
<div class="row items-center q-gutter-sm q-mb-md">
<QInput
v-model="filter"
@@ -262,19 +235,14 @@ const handleImport = async (event: Event) => {
</template>
</QInput>
<span class="text-caption text-grey-6">{{ rows.length }} players</span>
<QSpace />
<QBtn
color="secondary"
outline
icon="file_upload"
no-caps
color="secondary" outline icon="file_upload" no-caps
:label="t('playersImport')"
@click="triggerImport"
/>
<QBtn
color="secondary"
outline
icon="file_download"
no-caps
color="secondary" outline icon="file_download" no-caps
:label="t('playersExport')"
@click="exportPlayers"
/>
@@ -286,9 +254,7 @@ const handleImport = async (event: Event) => {
@change="handleImport"
>
</div>
</div>
<div class="col-12 col-lg-8 players-main-column">
<QTable
flat
bordered
@@ -305,261 +271,198 @@ const handleImport = async (event: Event) => {
<QChip
v-if="playerSource(row.id) === 'startgg'"
dense
outline
color="blue-4"
class="q-my-none q-mr-none q-ml-xs"
style="font-size: 10px; height: 18px;"
style="height: 18px; padding: 0 4px; background: transparent;"
>
start.gg
<QTooltip v-if="playerExpiresAt(row.id)">
Temporary · expires {{ formatExpiresAt(playerExpiresAt(row.id)!) }}
<svg style="width: 12px; height: 12px; flex-shrink: 0;" 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>
<QTooltip>
start.gg<template v-if="playerExpiresAt(row.id)"> · Temporary · expires {{ formatExpiresAt(playerExpiresAt(row.id)!) }}</template>
</QTooltip>
</QChip>
<QChip
v-else-if="playerSource(row.id) === 'challonge'"
dense
outline
color="orange-4"
class="q-my-none q-mr-none q-ml-xs"
style="font-size: 10px; height: 18px;"
style="height: 18px; padding: 0 4px; background: transparent;"
>
Challonge
<QTooltip v-if="playerExpiresAt(row.id)">
Temporary · expires {{ formatExpiresAt(playerExpiresAt(row.id)!) }}
<img src="https://challonge.com/favicon.ico" alt="Challonge" style="width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0;">
<QTooltip>
Challonge<template v-if="playerExpiresAt(row.id)"> · Temporary · expires {{ formatExpiresAt(playerExpiresAt(row.id)!) }}</template>
</QTooltip>
</QChip>
</div>
<div
v-if="row.name"
class="text-caption text-grey-6"
>
<div v-if="row.name" class="text-caption text-grey-6">
{{ row.name }}
</div>
</QTd>
</template>
<template #body-cell-actions="{ row }">
<QTd align="right">
<QBtn
size="sm"
flat
icon="edit"
@click="openEditDialog(row)"
/>
<QBtn
size="sm"
flat
color="negative"
icon="delete"
@click="deletePlayer(row)"
/>
<QBtn size="sm" flat icon="edit" @click="openEditDialog(row)" />
<QBtn size="sm" flat color="negative" icon="delete" @click="deletePlayer(row)" />
</QTd>
</template>
</QTable>
</div>
<!-- Panel de integraciones -->
<div class="col-12 col-lg-4 players-startgg-column">
<!-- Columna lateral: importar desde torneo -->
<div class="col-12 col-lg-4 players-import-column">
<div class="players-integrations-stack">
<!-- start.gg -->
<!-- start.gg -->
<QCard flat bordered class="q-pa-md">
<div class="text-h6 q-mb-sm startgg-heading">
<svg class="startgg-heading__icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 0A5.999 5.999 0 00.002 6v5.252a.75.75 0 00.75.748H5.25a.748.748 0 00.75-.747V6.749C6 6.334 6.336 6 6.748 6h16.497a.748.748 0 00.749-.748V.749A.743.743 0 0023.247 0zm12.75 12a.748.748 0 00-.75.75v4.5a.748.748 0 01-.747.748H.753a.754.754 0 00-.75.751v4.5a.75.75 0 00.75.751H18a5.999 5.999 0 005.999-6v-5.25a.75.75 0 00-.75-.75z" />
<div class="row items-center q-mb-xs q-gutter-x-sm">
<svg style="width: 18px; height: 18px; flex-shrink: 0;" 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>
<span>start.gg</span>
</div>
<div class="text-caption text-grey-6 q-mb-md">
{{ t('playersStartggHelp') }}
</div>
<div class="row q-col-gutter-sm items-center">
<div class="col-auto">
<QBtn
v-if="!startgg.hasTokenConfigured"
color="primary"
icon="login"
no-caps
:label="t('playersConnectStartgg')"
:loading="startgg.oauthLoading"
@click="startgg.connectWithOAuth"
/>
<QBtn
v-else
outline
color="positive"
icon="check_circle"
no-caps
:label="t('playersConnected')"
class="startgg-connected-btn"
@click="openStartggManualDialog"
/>
</div>
<div class="col-auto">
<QBtn
outline
color="white"
icon="vpn_key"
no-caps
:label="t('playersUsePersonalApi')"
@click="openStartggManualDialog"
/>
</div>
</div>
<div v-if="startgg.tournamentsError" class="text-negative q-mt-sm">
{{ startgg.tournamentsError }}
</div>
<div class="row items-center q-mt-md startgg-tournament-row">
<QBtn
flat round dense
<span class="text-subtitle2">start.gg</span>
<QSpace />
<QChip
v-if="startgg.hasTokenConfigured"
dense size="sm"
:color="startgg.hasValidatedToken ? 'positive' : 'warning'"
text-color="white"
icon="sync"
class="startgg-refresh-btn"
:loading="startgg.loadingTournaments"
@click="startgg.loadRecentTournaments"
/>
<div class="col">
<QSelect
v-model="startgg.selectedTournamentSlug"
v-model:input-value="startgg.tournamentInput"
:options="startgg.filteredTournamentOptions"
option-value="value"
option-label="label"
emit-value
map-options
use-input
hide-selected
fill-input
input-debounce="0"
clearable
dense
:label="t('playersTournament')"
class="players-underlined-field"
@filter="startgg.filterTournaments"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
<QItemLabel caption>{{ scope.opt.caption }}</QItemLabel>
</QItemSection>
</QItem>
</template>
</QSelect>
</div>
<div v-if="startgg.canImportSelectedTournament" class="col-auto">
<QBtn
color="primary"
unelevated
round
icon="person_add"
:aria-label="t('playersImportPlayers')"
@click="startgg.openSelectedTournamentImportDialog"
>
<QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
</QBtn>
</div>
>
{{ t('playersConnected') }}
</QChip>
<QChip v-else dense size="sm" color="grey-7" text-color="white" icon="link_off">
{{ t('settingsNotConnected') || 'Not connected' }}
</QChip>
</div>
<!-- Sin token: aviso con enlace a Settings -->
<div v-if="!startgg.hasTokenConfigured" class="text-caption text-grey-6 q-mt-sm">
{{ t('playersConnectInSettings') || 'Connect your start.gg account in' }}
<RouterLink to="/settings" class="text-primary">Settings</RouterLink>
{{ t('playersConnectInSettingsSuffix') || 'to import players from tournaments.' }}
</div>
<!-- Con token: selector de torneo -->
<template v-else>
<div v-if="startgg.tournamentsError" class="text-negative text-caption q-mt-xs">
{{ startgg.tournamentsError }}
</div>
<div class="row items-center q-mt-sm players-tournament-row">
<QBtn
flat round dense text-color="white" icon="sync"
class="players-refresh-btn"
:loading="startgg.loadingTournaments"
@click="startgg.loadRecentTournaments"
>
<QTooltip>Refresh tournaments</QTooltip>
</QBtn>
<div class="col">
<QSelect
v-model="startgg.selectedTournamentSlug"
v-model:input-value="startgg.tournamentInput"
:options="startgg.filteredTournamentOptions"
option-value="value" option-label="label"
emit-value map-options use-input hide-selected fill-input
input-debounce="0" clearable dense
:label="t('playersTournament')"
class="players-underlined-field"
@filter="startgg.filterTournaments"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
<QItemLabel caption>{{ scope.opt.caption }}</QItemLabel>
</QItemSection>
</QItem>
</template>
</QSelect>
</div>
<div v-if="startgg.canImportSelectedTournament" class="col-auto q-ml-xs">
<QBtn
color="primary" unelevated round icon="person_add"
:aria-label="t('playersImportPlayers')"
@click="startgg.openSelectedTournamentImportDialog"
>
<QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
</QBtn>
</div>
</div>
</template>
</QCard>
<!-- Challonge -->
<!-- Challonge -->
<QCard flat bordered class="q-pa-md">
<div class="text-h6 q-mb-sm startgg-heading">
<img
class="challonge-heading__icon"
src="https://challonge.com/favicon.ico"
alt="Challonge"
>
<span>Challonge</span>
</div>
<div class="text-caption text-grey-6 q-mb-md">
{{ t('playersChallongeHelp') }}
</div>
<div class="row q-col-gutter-sm items-center">
<div class="col-auto">
<QBtn
v-if="!challonge.hasTokenConfigured"
color="primary"
icon="login"
no-caps
:label="t('playersConnectChallonge')"
:loading="challonge.oauthLoading"
@click="challonge.connectWithOAuth"
/>
<QBtn
v-else
outline
:color="challonge.hasValidatedToken ? 'positive' : 'warning'"
icon="check_circle"
no-caps
:label="challongeConnectionLabel"
@click="openChallongeManualDialog"
/>
</div>
<div class="col-auto">
<QBtn
outline
color="white"
icon="vpn_key"
no-caps
:label="t('playersUsePersonalApi')"
@click="openChallongeManualDialog"
/>
</div>
</div>
<div v-if="challonge.tournamentsError" class="text-negative q-mt-sm">
{{ challonge.tournamentsError }}
</div>
<div class="row items-center q-mt-md startgg-tournament-row">
<QBtn
flat round dense
<div class="row items-center q-mb-xs q-gutter-x-sm">
<img src="https://challonge.com/favicon.ico" alt="Challonge" style="width: 18px; height: 18px; border-radius: 4px; flex-shrink: 0;">
<span class="text-subtitle2">Challonge</span>
<QSpace />
<QChip
v-if="challonge.hasTokenConfigured"
dense size="sm"
:color="challonge.hasValidatedToken ? 'positive' : 'warning'"
text-color="white"
icon="sync"
class="startgg-refresh-btn"
:loading="challonge.loadingTournaments"
@click="challonge.loadRecentTournaments"
/>
<div class="col">
<QSelect
v-model="challonge.selectedTournamentSlug"
v-model:input-value="challonge.tournamentInput"
:options="challonge.filteredTournamentOptions"
option-value="value"
option-label="label"
emit-value
map-options
use-input
hide-selected
fill-input
input-debounce="0"
clearable
dense
:label="t('playersTournament')"
class="players-underlined-field"
@filter="challonge.filterTournaments"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
<QItemLabel caption>{{ scope.opt.caption }}</QItemLabel>
</QItemSection>
</QItem>
</template>
</QSelect>
</div>
<div v-if="challonge.canImportSelectedTournament" class="col-auto">
<QBtn
color="primary"
unelevated
round
icon="person_add"
:aria-label="t('playersImportPlayers')"
@click="challonge.openSelectedTournamentImportDialog"
>
<QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
</QBtn>
</div>
>
{{ challonge.hasValidatedToken ? t('playersConnected') : 'Token set' }}
</QChip>
<QChip v-else dense size="sm" color="grey-7" text-color="white" icon="link_off">
{{ t('settingsNotConnected') || 'Not connected' }}
</QChip>
</div>
<!-- Sin token: aviso -->
<div v-if="!challonge.hasTokenConfigured" class="text-caption text-grey-6 q-mt-sm">
{{ t('playersConnectInSettings') || 'Connect your Challonge account in' }}
<RouterLink to="/settings" class="text-primary">Settings</RouterLink>
{{ t('playersConnectInSettingsSuffix') || 'to import players from tournaments.' }}
</div>
<!-- Con token: selector de torneo -->
<template v-else>
<div v-if="challonge.tournamentsError" class="text-negative text-caption q-mt-xs">
{{ challonge.tournamentsError }}
</div>
<div class="row items-center q-mt-sm players-tournament-row">
<QBtn
flat round dense text-color="white" icon="sync"
class="players-refresh-btn"
:loading="challonge.loadingTournaments"
@click="challonge.loadRecentTournaments"
>
<QTooltip>Refresh tournaments</QTooltip>
</QBtn>
<div class="col">
<QSelect
v-model="challonge.selectedTournamentSlug"
v-model:input-value="challonge.tournamentInput"
:options="challonge.filteredTournamentOptions"
option-value="value" option-label="label"
emit-value map-options use-input hide-selected fill-input
input-debounce="0" clearable dense
:label="t('playersTournament')"
class="players-underlined-field"
@filter="challonge.filterTournaments"
>
<template #option="scope">
<QItem v-bind="scope.itemProps">
<QItemSection>
<QItemLabel>{{ scope.opt.label }}</QItemLabel>
<QItemLabel caption>{{ scope.opt.caption }}</QItemLabel>
</QItemSection>
</QItem>
</template>
</QSelect>
</div>
<div v-if="challonge.canImportSelectedTournament" class="col-auto q-ml-xs">
<QBtn
color="primary" unelevated round icon="person_add"
:aria-label="t('playersImportPlayers')"
@click="challonge.openSelectedTournamentImportDialog"
>
<QTooltip>{{ t('playersImportPlayers') }}</QTooltip>
</QBtn>
</div>
</div>
</template>
</QCard>
</div>
@@ -570,9 +473,7 @@ const handleImport = async (event: Event) => {
<QDialog v-model="startgg.importDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">
Import from {{ startgg.importingTournament?.name || 'start.gg' }}
</div>
<div class="text-h6">Import from {{ startgg.importingTournament?.name || 'start.gg' }}</div>
</QCardSection>
<QSeparator />
<QCardSection>
@@ -617,9 +518,7 @@ const handleImport = async (event: Event) => {
<QDialog v-model="challonge.importDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">
Import from {{ challonge.importingTournament?.name || 'Challonge' }}
</div>
<div class="text-h6">Import from {{ challonge.importingTournament?.name || 'Challonge' }}</div>
</QCardSection>
<QSeparator />
<QCardSection>
@@ -660,65 +559,6 @@ const handleImport = async (event: Event) => {
</QCard>
</QDialog>
<!-- Diálogo token personal start.gg -->
<QDialog v-model="isStartggManualDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">Personal start.gg API</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div class="text-body2 q-mb-sm">
If OAuth fails, you can create your personal token manually with these steps:
</div>
<ol class="q-pl-md q-mb-md manual-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 into Scoreko</li>
</ol>
<QInput
v-model="startggManualDraft"
label="Paste your personal token"
dense outlined type="password"
/>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn flat no-caps label="Cancel" color="secondary" @click="isStartggManualDialogOpen = false" />
<QBtn flat no-caps color="negative" label="Delete token" @click="startggManualDraft = ''; saveStartggManualToken()" />
<QBtn no-caps color="primary" label="Save token" @click="saveStartggManualToken" />
</QCardActions>
</QCard>
</QDialog>
<!-- Diálogo token personal Challonge -->
<QDialog v-model="isChallongeManualDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">Personal Challonge API</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div class="text-body2 q-mb-sm">
If OAuth fails, paste a personal Challonge API token.
</div>
<QInput
v-model="challongeManualDraft"
label="Paste your personal Challonge token"
dense outlined type="password"
/>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn flat no-caps label="Cancel" color="secondary" @click="isChallongeManualDialogOpen = false" />
<QBtn flat no-caps color="negative" label="Delete token" @click="challongeManualDraft = ''; saveChallongeManualToken()" />
<QBtn no-caps color="primary" label="Save token" @click="saveChallongeManualToken" />
</QCardActions>
</QCard>
</QDialog>
<!-- Diálogo crear / editar jugador -->
<QDialog v-model="isDialogOpen">
<QCard class="players-dialog">
@@ -750,18 +590,11 @@ const handleImport = async (event: Event) => {
v-model="form.country"
v-model:input-value="countryInput"
:options="filteredCountryOptions"
option-value="value"
option-label="label"
emit-value
map-options
use-input
input-debounce="0"
hide-selected
fill-input
clearable
option-value="value" option-label="label"
emit-value map-options use-input input-debounce="0"
hide-selected fill-input clearable
:label="t('playersLabelCountry')"
dense
class="players-underlined-field"
dense class="players-underlined-field"
@filter="filterCountries"
/>
</div>
@@ -781,6 +614,7 @@ const handleImport = async (event: Event) => {
</QCardActions>
</QCard>
</QDialog>
</QPage>
</template>
@@ -804,8 +638,8 @@ const handleImport = async (event: Event) => {
flex: 1 1 auto;
}
.players-startgg-column {
min-width: 320px;
.players-import-column {
min-width: 300px;
}
.players-integrations-stack {
@@ -819,36 +653,14 @@ const handleImport = async (event: Event) => {
width: min(720px, 90vw);
}
.startgg-heading {
display: inline-flex;
align-items: center;
gap: 8px;
}
.startgg-heading__icon {
width: 20px;
height: 20px;
fill: #2e75ba;
}
.challonge-heading__icon {
width: 20px;
height: 20px;
border-radius: 4px;
}
.startgg-tournament-row {
.players-tournament-row {
gap: 4px;
}
.startgg-refresh-btn:hover {
.players-refresh-btn:hover {
background: rgba(255, 255, 255, 0.12);
}
.startgg-connected-btn {
font-weight: 600;
}
.players-underlined-field :deep(.q-field__control) {
min-height: 28px;
padding: 0;
@@ -862,10 +674,6 @@ const handleImport = async (event: Event) => {
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
}
.manual-token-steps {
line-height: 1.5;
}
.visually-hidden {
position: absolute;
width: 1px;
+398 -152
View File
@@ -1,18 +1,23 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue';
import { computed, onBeforeUnmount, ref } from 'vue';
import { useQuasar } from 'quasar';
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useIntegration } from '../composables/useIntegration';
import type { Locale } from '../i18n';
import { locale, setLocale, t } from '../i18n';
import { usePlayersStore } from '../../stores/players';
import {
eventToShortcut,
type ShortcutAction,
useShortcutSettingsStore,
} from '../stores/shortcut-settings';
} from '../../stores/shortcut-settings';
defineOptions({ name: 'SettingsView' });
useHead(() => ({ title: t('settingsTitle') }));
// ─── Idioma ────────────────────────────────────────────────────────────────────
const languageOptions = computed(() => [
{ label: t('languageSpanish'), value: 'es' as const },
{ label: t('languageEnglish'), value: 'en' as const },
@@ -20,15 +25,13 @@ const languageOptions = computed(() => [
const selectedLanguage = computed<Locale>({
get: () => locale.value,
set: (value) => {
setLocale(value);
},
set: (value) => { setLocale(value); },
});
// ─── Atajos de teclado ─────────────────────────────────────────────────────────
const shortcutSettingsStore = useShortcutSettingsStore();
const recordingAction = ref<ShortcutAction | null>(null);
// Ref para detectar clicks fuera del contenedor de atajos
const shortcutsContainerRef = ref<HTMLElement | null>(null);
const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: string }[]>(() => [
@@ -38,7 +41,6 @@ const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: s
{ action: 'rightDecrement', label: t('settingsShortcutRightDecrementLabel'), hint: t('settingsShortcutRightDecrementHint') },
]);
// Detecta atajos duplicados entre acciones
const conflictingActions = computed(() => {
const seen = new Map<string, ShortcutAction>();
const conflicts = new Set<ShortcutAction>();
@@ -62,43 +64,24 @@ const stopRecording = () => {
const onRecordKeydown = (event: KeyboardEvent) => {
if (!recordingAction.value) return;
// Escape cancela la grabación sin asignar ningún atajo
if (event.key === 'Escape') {
event.preventDefault();
stopRecording();
return;
}
if (event.key === 'Escape') { event.preventDefault(); stopRecording(); return; }
const shortcut = eventToShortcut(event);
if (!shortcut) return;
event.preventDefault();
shortcutSettingsStore.setShortcut(recordingAction.value, shortcut);
stopRecording();
};
// Click fuera del área de atajos también cancela la grabación
const onDocumentMousedown = (event: MouseEvent) => {
if (
recordingAction.value &&
shortcutsContainerRef.value &&
!shortcutsContainerRef.value.contains(event.target as Node)
) {
if (recordingAction.value && shortcutsContainerRef.value && !shortcutsContainerRef.value.contains(event.target as Node)) {
stopRecording();
}
};
const startRecording = (action: ShortcutAction) => {
if (recordingAction.value === action) {
stopRecording();
return;
}
if (recordingAction.value === action) { stopRecording(); return; }
recordingAction.value = action;
if (typeof document !== 'undefined') {
document.body.dataset.shortcutRecording = 'true';
}
if (typeof document !== 'undefined') document.body.dataset.shortcutRecording = 'true';
};
if (typeof window !== 'undefined') {
@@ -113,6 +96,79 @@ onBeforeUnmount(() => {
}
stopRecording();
});
// ─── 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>
<template>
@@ -126,134 +182,324 @@ onBeforeUnmount(() => {
</div>
</div>
<QCard
flat
bordered
class="settings-card"
>
<!-- Language -->
<QCardSection class="q-pa-lg">
<!--
Label movido al propio QSelect (más idiomático en Quasar con outlined).
Se elimina el text-overline redundante de encima.
-->
<QSelect
v-model="selectedLanguage"
emit-value
map-options
:options="languageOptions"
:label="t('settingsLanguageLabel')"
outlined
dense
/>
<div class="column q-gutter-lg settings-layout">
<div class="text-caption text-grey-6 q-mt-sm">
{{ t('settingsLanguageHint') }}
</div>
</QCardSection>
<QSeparator />
<!-- Shortcuts -->
<QCardSection class="q-pa-lg">
<div class="row items-center justify-between q-mb-xs">
<div class="text-overline text-grey-6">
{{ t('settingsShortcutTitle') }}
</div>
<QBtn
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>
<!-- Aviso de conflicto: se muestra si dos acciones comparten el mismo atajo -->
<QBanner
v-if="conflictingActions.size > 0"
class="bg-warning text-white q-mb-md"
rounded
dense
>
<template #avatar>
<QIcon name="warning" color="white" />
</template>
{{ t('settingsShortcutConflictWarning') }}
</QBanner>
<!--
ref="shortcutsContainerRef" permite detectar clicks fuera
de esta área para cancelar la grabación automáticamente.
-->
<div
ref="shortcutsContainerRef"
class="column q-gutter-md"
>
<QInput
v-for="field in shortcutFields"
:key="field.action"
:model-value="shortcutSettingsStore.shortcuts[field.action]"
:hint="recordingAction === field.action ? t('settingsShortcutRecordingHint') : field.hint"
:color="
recordingAction === field.action
? 'negative'
: conflictingActions.has(field.action)
? 'warning'
: 'primary'
"
readonly
<!-- Idioma -->
<QCard flat bordered class="settings-card">
<QCardSection class="q-pa-lg">
<div class="text-overline text-grey-6 q-mb-md">{{ t('settingsLanguageLabel') }}</div>
<QSelect
v-model="selectedLanguage"
emit-value
map-options
:options="languageOptions"
:label="t('settingsLanguageLabel')"
outlined
dense
bottom-slots
:label="field.label"
>
<template #append>
<!-- Botón grabar / detener -->
<QBtn
flat
round
dense
:icon="recordingAction === field.action ? 'stop_circle' : 'keyboard'"
:color="recordingAction === field.action ? 'negative' : 'primary'"
:aria-label="
recordingAction === field.action
? t('settingsShortcutStopRecording')
: t('settingsShortcutStartRecording')
"
@click="startRecording(field.action)"
/>
style="max-width: 280px"
/>
<div class="text-caption text-grey-6 q-mt-sm">
{{ t('settingsLanguageHint') }}
</div>
</QCardSection>
</QCard>
<!-- Botón reset individual por atajo -->
<QBtn
flat
round
dense
icon="restart_alt"
color="grey-5"
:aria-label="t('settingsShortcutResetSingle')"
@click="shortcutSettingsStore.resetShortcut(field.action)"
>
<QTooltip>{{ t('settingsShortcutResetSingle') }}</QTooltip>
</QBtn>
<!-- Integraciones -->
<QCard flat bordered class="settings-card">
<QCardSection class="q-pa-lg">
<div class="text-overline text-grey-6 q-mb-xs">{{ t('settingsIntegrationsTitle') || 'Integrations' }}</div>
<div class="text-caption text-grey-6 q-mb-lg">
{{ t('settingsIntegrationsDescription') || 'Connect your tournament platform accounts to import players directly from brackets.' }}
</div>
<div class="column q-gutter-md">
<!-- start.gg -->
<div class="integration-row">
<div class="integration-row__logo">
<svg style="width: 28px; height: 28px;" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<path d="M1.25 20h7.5A1.25 1.25 0 0 0 10 18.75v-7.5A1.25 1.25 0 0 1 11.25 10h27.5A1.25 1.25 0 0 0 40 8.75V1.25A1.25 1.25 0 0 0 38.75 0H10A10 10 0 0 0 0 10v8.75A1.25 1.25 0 0 0 1.25 20Z" fill="#3f80ff" />
<path d="M38.75 20h-7.5A1.25 1.25 0 0 0 30 21.25v7.5A1.25 1.25 0 0 1 28.75 30H1.25A1.25 1.25 0 0 0 0 31.25v7.5A1.25 1.25 0 0 0 1.25 40H30A10 10 0 0 0 40 30V21.25A1.25 1.25 0 0 0 38.75 20Z" fill="#ff2768" />
</svg>
</div>
<div class="integration-row__info">
<div class="text-body2 text-weight-medium">start.gg</div>
<div class="text-caption text-grey-6">{{ t('playersStartggHelp') }}</div>
</div>
<div class="integration-row__actions row q-gutter-sm items-center">
<QChip
v-if="startgg.hasTokenConfigured"
dense
:color="startgg.hasValidatedToken ? 'positive' : 'warning'"
text-color="white"
icon="check_circle"
>
{{ t('playersConnected') }}
</QChip>
<QBtn
v-if="!startgg.hasTokenConfigured"
color="primary"
icon="login"
no-caps
unelevated
:label="t('playersConnectStartgg')"
:loading="startgg.oauthLoading"
@click="startgg.connectWithOAuth"
/>
<QBtn
v-else
flat
color="negative"
icon="link_off"
no-caps
size="sm"
:label="t('settingsDisconnect') || 'Disconnect'"
@click="startggManualDraft = ''; startgg.token = ''; $q.notify({ type: 'info', message: 'start.gg disconnected.' })"
/>
<QBtn
outline
:color="startgg.hasTokenConfigured ? 'grey-5' : 'white'"
icon="vpn_key"
no-caps
size="sm"
:label="t('playersUsePersonalApi')"
@click="openStartggManualDialog"
/>
</div>
</div>
<QSeparator />
<!-- Challonge -->
<div class="integration-row">
<div class="integration-row__logo">
<img
src="https://challonge.com/favicon.ico"
alt="Challonge"
style="width: 28px; height: 28px; border-radius: 6px;"
>
</div>
<div class="integration-row__info">
<div class="text-body2 text-weight-medium">Challonge</div>
<div class="text-caption text-grey-6">{{ t('playersChallongeHelp') }}</div>
</div>
<div class="integration-row__actions row q-gutter-sm items-center">
<QChip
v-if="challonge.hasTokenConfigured"
dense
:color="challonge.hasValidatedToken ? 'positive' : 'warning'"
text-color="white"
icon="check_circle"
>
{{ challongeConnectionLabel }}
</QChip>
<QBtn
v-if="!challonge.hasTokenConfigured"
color="primary"
icon="login"
no-caps
unelevated
:label="t('playersConnectChallonge')"
:loading="challonge.oauthLoading"
@click="challonge.connectWithOAuth"
/>
<QBtn
v-else
flat
color="negative"
icon="link_off"
no-caps
size="sm"
:label="t('settingsDisconnect') || 'Disconnect'"
@click="challongeManualDraft = ''; challonge.token = ''; $q.notify({ type: 'info', message: 'Challonge disconnected.' })"
/>
<QBtn
outline
:color="challonge.hasTokenConfigured ? 'grey-5' : 'white'"
icon="vpn_key"
no-caps
size="sm"
:label="t('playersUsePersonalApi')"
@click="openChallongeManualDialog"
/>
</div>
</div>
</div>
</QCardSection>
</QCard>
<!-- Atajos de teclado -->
<QCard flat bordered class="settings-card">
<QCardSection class="q-pa-lg">
<div class="row items-center justify-between q-mb-xs">
<div class="text-overline text-grey-6">
{{ t('settingsShortcutTitle') }}
</div>
<QBtn
round dense flat color="primary" icon="restart_alt"
:aria-label="t('settingsShortcutReset')"
@click="shortcutSettingsStore.resetShortcuts"
>
<QTooltip>{{ t('settingsShortcutReset') }}</QTooltip>
</QBtn>
</div>
<div class="text-caption text-grey-6 q-mb-lg">
{{ t('settingsShortcutDescription') }}
</div>
<QBanner
v-if="conflictingActions.size > 0"
class="bg-warning text-white q-mb-md"
rounded dense
>
<template #avatar>
<QIcon name="warning" color="white" />
</template>
</QInput>
</div>
</QCardSection>
</QCard>
{{ t('settingsShortcutConflictWarning') }}
</QBanner>
<div ref="shortcutsContainerRef" class="column q-gutter-md">
<QInput
v-for="field in shortcutFields"
:key="field.action"
:model-value="shortcutSettingsStore.shortcuts[field.action]"
:hint="recordingAction === field.action ? t('settingsShortcutRecordingHint') : field.hint"
:color="
recordingAction === field.action
? 'negative'
: conflictingActions.has(field.action)
? 'warning'
: 'primary'
"
readonly outlined dense bottom-slots
:label="field.label"
>
<template #append>
<QBtn
flat round dense
:icon="recordingAction === field.action ? 'stop_circle' : 'keyboard'"
:color="recordingAction === field.action ? 'negative' : 'primary'"
:aria-label="recordingAction === field.action ? t('settingsShortcutStopRecording') : t('settingsShortcutStartRecording')"
@click="startRecording(field.action)"
/>
<QBtn
flat round dense icon="restart_alt" color="grey-5"
:aria-label="t('settingsShortcutResetSingle')"
@click="shortcutSettingsStore.resetShortcut(field.action)"
>
<QTooltip>{{ t('settingsShortcutResetSingle') }}</QTooltip>
</QBtn>
</template>
</QInput>
</div>
</QCardSection>
</QCard>
</div>
<!-- Diálogo token personal start.gg -->
<QDialog v-model="isStartggManualDialogOpen">
<QCard class="settings-dialog">
<QCardSection>
<div class="text-h6">Personal start.gg API token</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div class="text-body2 q-mb-sm">
If OAuth fails, you can create a personal token manually:
</div>
<ol class="q-pl-md q-mb-md settings-token-steps">
<li>Go to https://start.gg/admin/profile/developer</li>
<li>Sign in with your account</li>
<li>From the 3 access tokens, click <strong>Third Party</strong></li>
<li>Create a new one and fill the description with any name you want</li>
<li>Copy the generated token and paste it below</li>
</ol>
<QInput
v-model="startggManualDraft"
label="Paste your personal token"
dense outlined type="password"
/>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn flat no-caps label="Cancel" color="secondary" @click="isStartggManualDialogOpen = false" />
<QBtn flat no-caps color="negative" label="Delete token" @click="startggManualDraft = ''; saveStartggManualToken()" />
<QBtn no-caps color="primary" label="Save token" @click="saveStartggManualToken" />
</QCardActions>
</QCard>
</QDialog>
<!-- Diálogo token personal Challonge -->
<QDialog v-model="isChallongeManualDialogOpen">
<QCard class="settings-dialog">
<QCardSection>
<div class="text-h6">Personal Challonge API token</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div class="text-body2 q-mb-sm">
If OAuth fails, paste a personal Challonge API token.
</div>
<QInput
v-model="challongeManualDraft"
label="Paste your personal Challonge token"
dense outlined type="password"
/>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn flat no-caps label="Cancel" color="secondary" @click="isChallongeManualDialogOpen = false" />
<QBtn flat no-caps color="negative" label="Delete token" @click="challongeManualDraft = ''; saveChallongeManualToken()" />
<QBtn no-caps color="primary" label="Save token" @click="saveChallongeManualToken" />
</QCardActions>
</QCard>
</QDialog>
</QPage>
</template>
<style scoped>
.settings-card {
max-width: 600px;
.settings-layout {
max-width: 680px;
}
</style>
.settings-card {
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>
@@ -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);
+79
View File
@@ -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 { 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> {
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>,
replicant: ReplicantLike<T> | undefined,
normalize: (input: unknown) => T,
@@ -44,24 +50,21 @@ export const syncStateWithReplicant = <T>(
): void => {
const isApplyingReplicant = ref(false);
const persistSnapshot = (value: T): void => {
if (!storageKey) {
return;
if (storageKey) {
writeStorageSnapshot(storageKey, value);
}
writeStorageSnapshot(storageKey, value);
};
watch(
() => replicant?.data,
(value) => {
if (!value) {
if (value === undefined) {
return;
}
isApplyingReplicant.value = true;
state.value = normalize(value);
isApplyingReplicant.value = false;
persistSnapshot(state.value);
},
{ deep: true, immediate: true },
@@ -70,16 +73,32 @@ export const syncStateWithReplicant = <T>(
watch(
state,
(value) => {
persistSnapshot(value);
const normalized = normalize(value);
persistSnapshot(normalized);
if (isApplyingReplicant.value || !replicant) {
return;
}
// Replicants remain the source of truth for server/browser synchronization.
replicant.data = normalize(value);
replicant.data = normalized;
replicant.save();
},
{ 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);
};
+77
View File
@@ -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,
};
});
+31
View File
@@ -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,
};
});
+218
View File
@@ -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,
};
});
+51
View File
@@ -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,
};
});
+120
View File
@@ -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,
};
});
+121 -47
View File
@@ -1,4 +1,6 @@
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 ────────────────────────────────────────────────────────────────
@@ -20,6 +22,14 @@ const CHALLONGE_OAUTH_DEFAULT_PORT = 34921;
const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 20;
// ─── 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 OAuthTokenResponse {
@@ -46,34 +56,54 @@ interface ImportedPlayer {
twitter: string;
}
// ─── Config OAuth ──────────────────────────────────────────────────────────────
// ─── Modo OAuth ────────────────────────────────────────────────────────────────
//
// 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 getOAuthConfig = (): OAuthConfig | null => {
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
const clientId = String(bundleConfig.challongeClientId ?? '').trim();
type OAuthMode =
| { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
| { 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 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;
const proxyBaseUrl =
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
return { clientId, clientSecret, callbackPort };
if (clientId && clientSecret) {
nodecg.log.info('[Challonge] OAuth: modo dev (credenciales locales)');
return { type: 'dev', clientId, clientSecret, callbackPort };
}
nodecg.log.info(`[Challonge] OAuth: modo proxy → ${proxyBaseUrl}`);
return { type: 'proxy', proxyBaseUrl, callbackPort };
};
// ─── Intercambio de token ──────────────────────────────────────────────────────
// ─── Exchange de token ─────────────────────────────────────────────────────────
const exchangeOAuthCodeForToken = async (
/** Modo dev: exchange directo con Challonge usando credenciales locales */
const exchangeCodeDirectly = async (
code: string,
redirectUri: string,
config: OAuthConfig,
clientId: string,
clientSecret: string,
): Promise<string> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
grant_type: 'authorization_code',
code,
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
});
const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, {
@@ -112,6 +142,51 @@ const exchangeOAuthCodeForToken = async (
return token;
};
/** Modo proxy: el Worker hace el exchange; el clientSecret nunca sale del Worker */
const exchangeCodeViaProxy = async (
code: string,
redirectUri: string,
proxyBaseUrl: string,
): Promise<string> => {
const response = await fetch(`${proxyBaseUrl}/oauth/challonge/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, redirectUri }),
});
const rawBody = await response.text();
let payload: { access_token?: string; error?: string };
try {
payload = JSON.parse(rawBody) as typeof payload;
} catch {
payload = { error: rawBody };
}
if (!response.ok) {
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
}
const token = String(payload.access_token ?? '').trim();
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
return token;
};
/**
* Callback que recibe oauth-server.ts cuando llega el código de autorización.
* Delega al modo correcto; _config no se usa porque el modo ya está determinado.
*/
const exchangeOAuthCodeForToken = async (
code: string,
redirectUri: string,
_config: OAuthConfig,
): Promise<string> => {
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({
@@ -137,17 +212,6 @@ const parseJsonResponse = async (response: Response): Promise<unknown> => {
}
};
/**
* Realiza una petición autenticada a la API de Challonge.
*
* Intenta primero con OAuth v2 (Bearer token).
* Si recibe 401, reintenta con autenticación v1 (API key personal pegada manualmente).
* En cualquier otro error no-2xx, lanza inmediatamente.
*
* CORRECCIÓN: en la versión anterior, el bloque de error final era dead code
* porque el body de v2 ya había sido consumido y la condición `!v2Response.ok`
* nunca se alcanzaba tras el fallback v1.
*/
const requestChallonge = async (path: string, token: string): Promise<unknown> => {
const requestUrl = `${CHALLONGE_API_BASE}${path}`;
@@ -297,19 +361,13 @@ const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
if (!id || !rawDisplayName) return;
// Detectar patrón "TEAM | Gamertag" o "TEAM |Gamertag" (muy común en fighting games).
// Si se detecta, extraer el equipo del propio nombre y limpiar el gamertag.
const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/;
const pipeMatch = PIPE_PATTERN.exec(rawDisplayName);
const teamFromName = pipeMatch ? pipeMatch[1].trim() : '';
const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName;
// team_name de la API tiene prioridad; si no existe, usar el extraído del nombre.
const team = String(attributes.team_name ?? '').trim() || teamFromName;
// Challonge no expone un campo de nombre real separado del username/display_name.
// Se deja vacío para no duplicar el gamertag en el campo name.
map.set(id, {
id,
gamertag,
@@ -360,28 +418,44 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
const config = getOAuthConfig();
if (!config) {
sendAck(
ack,
'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge OAuth app.',
);
return;
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 {
await oauthServer.ensureServer(config);
await oauthServer.ensureServer(serverConfig);
} catch (err) {
sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback');
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
return;
}
const session = oauthServer.createSession(config);
sendAck(ack, null, session);
sendAck(ack, null, oauthServer.createSession(serverConfig));
});
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
listenForMessage(messageNames.integrations.challonge.getOAuthSessionStatus, (payload: unknown, ack) => {
const sessionId = getStringProp(payload, 'sessionId');
if (!sessionId) {
sendAck(ack, 'Missing OAuth session id');
@@ -397,7 +471,7 @@ nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
sendAck(ack, null, status);
});
nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ack) => {
listenForMessage(messageNames.integrations.challonge.fetchRecentTournaments, async (payload: unknown, ack) => {
const token = getStringProp(payload, 'token');
if (!token) {
sendAck(ack, 'Missing Challonge API token');
@@ -415,7 +489,7 @@ nodecg.listenFor('challonge:fetchRecentTournaments', async (payload: unknown, ac
}
});
nodecg.listenFor('challonge:fetchTournamentPlayers', async (payload: unknown, ack) => {
listenForMessage(messageNames.integrations.challonge.fetchTournamentPlayers, async (payload: unknown, ack) => {
const token = getStringProp(payload, 'token');
const slug = normalizeTournamentSlug(getStringProp(payload, 'slug'));
-15
View File
@@ -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' };
+4 -4
View File
@@ -1,14 +1,14 @@
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) => {
/**
* 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.
*/
set(nodecg); // set nodecg "context" before anything else
await import('./util/replicants.js'); // make sure replicants are set up
await import('./example.js');
setNodecgContext(nodecg); // set nodecg "context" before anything else
await import('./modules/replicants.js'); // make sure replicants are set up
await import('./startgg.js');
await import('./challonge.js');
await import('./pack-manager.js');
};
+9
View File
@@ -0,0 +1,9 @@
import {
commentaryReplicant,
playersReplicant,
scoreboardReplicant,
} from '../../nodecg/extension/replicants.js';
playersReplicant();
scoreboardReplicant();
commentaryReplicant();
+390
View File
@@ -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}`));
}
});
+123 -31
View File
@@ -1,6 +1,8 @@
import { getData, type CountryRecord } from 'country-list';
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';
import { nodecg } from './util/nodecg.js';
// ─── Constantes ────────────────────────────────────────────────────────────────
@@ -18,6 +20,14 @@ const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 12;
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> {
@@ -49,22 +59,41 @@ interface OAuthTokenResponse {
message?: string;
}
// ─── Config OAuth ──────────────────────────────────────────────────────────────
// ─── Modo OAuth ────────────────────────────────────────────────────────────────
//
// 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 getOAuthConfig = (): OAuthConfig | null => {
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>;
const clientId = String(bundleConfig.startggClientId ?? '').trim();
type OAuthMode =
| { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
| { 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 rawPort = Number(bundleConfig.startggOAuthPort ?? STARTGG_OAUTH_DEFAULT_PORT);
const callbackPort =
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT;
if (!clientId || !clientSecret) return null;
// oauthProxyUrl en config permite apuntar a un proxy distinto sin recompilar
const proxyBaseUrl =
String(bundleConfig.oauthProxyUrl ?? '').trim() || OAUTH_PROXY_BASE_URL;
return { clientId, clientSecret, callbackPort };
if (clientId && clientSecret) {
nodecg.log.info('[start.gg] OAuth: modo dev (credenciales locales)');
return { type: 'dev', clientId, clientSecret, callbackPort };
}
nodecg.log.info(`[start.gg] OAuth: modo proxy → ${proxyBaseUrl}`);
return { type: 'proxy', proxyBaseUrl, callbackPort };
};
// ─── Intercambio de token (multi-endpoint) ─────────────────────────────────────
// ─── Exchange de token ─────────────────────────────────────────────────────────
const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
const rawBody = await response.text();
@@ -75,17 +104,19 @@ const parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenRes
}
};
const exchangeOAuthCodeForToken = async (
/** Modo dev: exchange directo con start.gg usando credenciales locales */
const exchangeCodeDirectly = async (
code: string,
redirectUri: string,
config: OAuthConfig,
clientId: string,
clientSecret: string,
): Promise<string> => {
const params = new URLSearchParams({
grant_type: 'authorization_code',
grant_type: 'authorization_code',
code,
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
});
let lastError = 'Unknown OAuth token exchange error';
@@ -116,13 +147,57 @@ const exchangeOAuthCodeForToken = async (
payload.message ??
`OAuth token request failed (${response.status})`;
// Solo 404 justifica probar el siguiente endpoint
if (response.status !== 404) break;
}
throw new Error(lastError);
};
/** 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({
@@ -202,28 +277,45 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
// ─── Listeners de NodeCG ───────────────────────────────────────────────────────
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
const config = getOAuthConfig();
if (!config) {
sendAck(
ack,
'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.',
);
return;
listenForMessage(messageNames.integrations.startgg.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 (va en la URL del navegador), pero no lo queremos en el repo.
try {
const res = await fetch(`${mode.proxyBaseUrl}/oauth/startgg/client-id`);
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
const data = await res.json() as { clientId?: string };
const clientId = String(data.clientId ?? '').trim();
if (!clientId) throw new Error('Proxy did not return a clientId');
serverConfig = { clientId, callbackPort: mode.callbackPort };
} catch (err) {
sendAck(
ack,
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
);
return;
}
}
try {
await oauthServer.ensureServer(config);
await oauthServer.ensureServer(serverConfig);
} catch (err) {
sendAck(ack, err instanceof Error ? err.message : 'Could not start the local OAuth callback');
sendAck(ack, err instanceof Error ? err.message : 'Could not start the OAuth callback server');
return;
}
const session = oauthServer.createSession(config);
sendAck(ack, null, session);
sendAck(ack, null, oauthServer.createSession(serverConfig));
});
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
listenForMessage(messageNames.integrations.startgg.getOAuthSessionStatus, (payload: unknown, ack) => {
const sessionId = getStringProp(payload, 'sessionId');
if (!sessionId) {
sendAck(ack, 'Missing OAuth session id');
@@ -239,7 +331,7 @@ nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
sendAck(ack, null, status);
});
nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
listenForMessage(messageNames.integrations.startgg.fetchRecentTournaments, async (payload: unknown, ack) => {
const token = getStringProp(payload, 'token');
if (!token) {
sendAck(ack, 'Missing start.gg API token');
@@ -279,7 +371,7 @@ nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack)
}
});
nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => {
listenForMessage(messageNames.integrations.startgg.fetchTournamentPlayers, async (payload: unknown, ack) => {
const token = getStringProp(payload, 'token');
const slug = getStringProp(payload, 'slug');
-7
View File
@@ -1,7 +0,0 @@
import type { NodeCGServerAPI } from '../../types/index.js';
export let nodecg!: NodeCGServerAPI;
export function set(ctx: NodeCGServerAPI) {
nodecg = ctx;
}
+3 -1
View File
@@ -5,7 +5,9 @@ import { randomUUID } from 'node:crypto';
export interface OAuthConfig {
clientId: string;
clientSecret: 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;
}
-31
View File
@@ -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,
});
+2 -10
View File
@@ -1,19 +1,11 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue';
import { computed } from 'vue';
import { commentaryReplicant } from '../../browser_shared/replicants';
import type { Schemas } from '../../types';
import { useCommentaryReplicatedState } from '../shared/services/replicated-state';
useHead({ title: 'Commentary' });
const defaultCommentary: Schemas.Commentary = {
leftCommentator: '',
leftCommentatorTwitter: '',
rightCommentator: '',
rightCommentatorTwitter: '',
};
const commentary = computed<Schemas.Commentary>(() => commentaryReplicant?.data ?? defaultCommentary);
const { commentary } = useCommentaryReplicatedState();
const leftCommentator = computed(() => commentary.value.leftCommentator || 'COMMENTATOR 1');
const rightCommentator = computed(() => commentary.value.rightCommentator || 'COMMENTATOR 2');
+3 -11
View File
@@ -1,21 +1,13 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue';
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue';
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
import { resolveCountryCode } from '../../shared/countries';
import { useScoreboardReplicatedState } from '../shared/services/replicated-state';
import { resolveCountryCode } from '../../shared/domain/players/countries';
import { getCharactersByGame } from '../../shared/fighting-characters';
import type { Schemas } from '../../types';
useHead({ title: 'Scoreboard 2XKO' });
const defaultScoreboard: Schemas.Scoreboard = {
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');
const { players, scoreboard, scoreboardSkin } = useScoreboardReplicatedState('scoreboard-2xko/main.html');
watch(
scoreboardSkin,
+3 -23
View File
@@ -1,32 +1,12 @@
<script setup lang="ts">
import { useHead } from '@unhead/vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { graphicsSettingsReplicant, playersReplicant, scoreboardReplicant } from '../../browser_shared/replicants';
import { resolveCountryCode } from '../../shared/countries';
import type { Schemas } from '../../types';
import { useScoreboardReplicatedState } from '../shared/services/replicated-state';
import { resolveCountryCode } from '../../shared/domain/players/countries';
useHead({ title: 'Scoreboard' });
const defaultScoreboard: Schemas.Scoreboard = {
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');
const { players, scoreboard, scoreboardSkin } = useScoreboardReplicatedState('scoreboard/main.html');
watch(
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,
};
};
+13
View File
@@ -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);
+52
View File
@@ -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),
};
};
+24
View File
@@ -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,
);
+26
View File
@@ -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];
},
});
+26
View File
@@ -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);
};
+36
View File
@@ -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,
},
),
};
};
+32
View File
@@ -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,
});
+28
View File
@@ -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];
+12
View File
@@ -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];
Binary file not shown.

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 716 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

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