9 Commits

Author SHA1 Message Date
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
98 changed files with 2856 additions and 1230 deletions
+1
View File
@@ -142,3 +142,4 @@ dist
/db/ /db/
*.sqlite3 *.sqlite3
/scoreko-electron-dev/ /scoreko-electron-dev/
/packs/
+10 -16
View File
@@ -3,45 +3,39 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"exampleProperty": { "oauthProxyUrl": {
"type": "string" "type": "string",
"description": "Sobreescribe la URL base del proxy OAuth (por defecto usa la constante del código). Útil para staging o desarrollo del proxy."
}, },
"startggClientId": { "startggClientId": {
"type": "string", "type": "string",
"default": "", "description": "DEV ONLY: Client ID de tu propia OAuth app de start.gg. Si está presente junto a startggClientSecret, activa el modo dev (exchange directo, sin proxy)."
"description": "Client ID de tu OAuth app de start.gg"
}, },
"startggClientSecret": { "startggClientSecret": {
"type": "string", "type": "string",
"default": "", "description": "DEV ONLY: Client Secret de tu propia OAuth app de start.gg. NUNCA subas este valor a git."
"description": "Client Secret de tu OAuth app de start.gg"
}, },
"startggOAuthPort": { "startggOAuthPort": {
"type": "integer", "type": "integer",
"default": 34920, "default": 34920,
"minimum": 1, "minimum": 1,
"maximum": 65535, "maximum": 65535,
"description": "Puerto local para callback OAuth" "description": "Puerto local para el servidor de callback OAuth de start.gg."
}, },
"challongeClientId": { "challongeClientId": {
"type": "string", "type": "string",
"default": "", "description": "DEV ONLY: Client ID de tu propia OAuth app de Challonge. Si está presente junto a challongeClientSecret, activa el modo dev."
"description": "Client ID de tu OAuth app de Challonge"
}, },
"challongeClientSecret": { "challongeClientSecret": {
"type": "string", "type": "string",
"default": "", "description": "DEV ONLY: Client Secret de tu propia OAuth app de Challonge. NUNCA subas este valor a git."
"description": "Client Secret de tu OAuth app de Challonge"
}, },
"challongeOAuthPort": { "challongeOAuthPort": {
"type": "integer", "type": "integer",
"default": 34921, "default": 34921,
"minimum": 1, "minimum": 1,
"maximum": 65535, "maximum": 65535,
"description": "Puerto local para callback OAuth de Challonge" "description": "Puerto local para el servidor de callback OAuth de Challonge."
} }
}, }
"required": [
"exampleProperty"
]
} }
-38
View File
@@ -2,35 +2,6 @@
import { ref } from 'vue'; import { ref } from 'vue';
const loadQuotes = [ const loadQuotes = [
// Misc
'Demanding rollback netcode',
'Disrespecting your plus frames',
'Taking your lunch money',
// Street Fighter
'Parrying your super',
'Fighting like gentlemen',
'Fighting a new rival',
'Keeping it classy',
"Protecting Russia's skies",
'Waking up with Dragon Punch',
'Teching those throws',
'Finding the heart of battle',
'Chucking plasma',
'Executing the Yeah Nah Yeah',
// Guilty Gear
'Counter-hitting Pilebunker',
'Riding the lightning',
'Knowing the smell of the game',
'Dropping the instant kill combo',
'What are you standing up for?!',
'Stealing your soul',
'Channelling your inner gorilla',
'Initiating danger time',
'Dragon Installing',
'Practising dust loops',
// BlazBlue
'Turning the wheel of fate',
'Escaping from crossing fate',
// Tekken // Tekken
"Complaining about Paul's damage", "Complaining about Paul's damage",
'Nerfing Gigas', 'Nerfing Gigas',
@@ -38,15 +9,6 @@ const loadQuotes = [
'Sidestepping your electric', 'Sidestepping your electric',
'Punishing hellsweep with 1,1,2', 'Punishing hellsweep with 1,1,2',
'Emailing Harada', 'Emailing Harada',
// Marvel
'Explaining the DHC glitch',
"When's Mahvel?",
'Thanking god for the machine',
'Setting up shop',
'Getting motivated',
'Activating X-Factor',
// Dragon Ball
'Adding yet another Goku',
]; ];
const randomIndex = Math.floor(Math.random() * loadQuotes.length); const randomIndex = Math.floor(Math.random() * loadQuotes.length);
@@ -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/pack-config';
import type { PackRegistryEntry } from '../../../shared/pack-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"> <script setup lang="ts">
import { computed, inject } from 'vue'; import { computed, inject } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { usePlayerSide } from '../composables/usePlayerSide';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame'; import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { usePlayerSide } from '../composables/usePlayerSide';
import { t } from '../i18n'; import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Props // Props
@@ -140,6 +140,19 @@ const sideImageLabel = computed(() => t(isLeft.value ? 'scoreboardLeftImage' : '
<template #prepend> <template #prepend>
<QIcon name="sports_martial_arts" /> <QIcon name="sports_martial_arts" />
</template> </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> </QSelect>
</div> </div>
@@ -372,6 +385,19 @@ const sideImageLabel = computed(() => t(isLeft.value ? 'scoreboardLeftImage' : '
<template #prepend> <template #prepend>
<QIcon name="sports_martial_arts" /> <QIcon name="sports_martial_arts" />
</template> </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> </QSelect>
</div> </div>
</template> </template>
@@ -481,6 +507,27 @@ const sideImageLabel = computed(() => t(isLeft.value ? 'scoreboardLeftImage' : '
color: rgba(255, 255, 255, 0.92); 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) { @media (max-width: 900px) {
.scoreboard-preview__image-wrap { .scoreboard-preview__image-wrap {
width: min(100%, 280px); width: min(100%, 280px);
@@ -1,11 +1,36 @@
<script setup lang="ts"> <script setup lang="ts">
import { inject } from 'vue'; import { inject, onMounted, onUnmounted, ref } from 'vue';
import { useScoreboardStore } from '../stores/scoreboard';
import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame'; import { CHARACTER_GAME_KEY } from '../composables/useCharacterGame';
import { usePackRegistry } from '../composables/usePackRegistry';
import { t } from '../i18n'; import { t } from '../i18n';
import { useScoreboardStore } from '../stores/scoreboard';
import GamePackDownloadDialog from './GamePackDownloadDialog.vue';
const scoreboardStore = useScoreboardStore(); 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.fetchRegistry();
});
const refreshInterval = setInterval(() => {
packRegistry.fetchRegistry();
}, 15_000);
onUnmounted(() => {
clearInterval(refreshInterval);
});
const adjustLeftScore = (delta: number) => { const adjustLeftScore = (delta: number) => {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta); scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore + delta);
@@ -14,12 +39,33 @@ const adjustLeftScore = (delta: number) => {
const adjustRightScore = (delta: number) => { const adjustRightScore = (delta: number) => {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta); scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore + delta);
}; };
/** Tras una descarga exitosa, activa el juego en el store. */
const onPackDownloaded = (gameName: string) => {
scoreboardStore.scoreboard.game = gameName;
};
// ── Estado del diálogo de actualización ───────────────────────────────────────
const pendingUpdateEntry = ref<import('../../../shared/pack-types').PackRegistryEntry | null>(null);
const pendingUpdateInfo = ref<{ installedVersion: string; latestVersion: string } | undefined>(undefined);
const showUpdateDialog = ref(false);
const openUpdateDialog = (opt: import('../../../shared/pack-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> </script>
<template> <template>
<div class="scoreboard-preview__center"> <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 <QSelect
v-model="scoreboardStore.scoreboard.game" :model-value="scoreboardStore.scoreboard.game"
v-model:input-value="gameInput" v-model:input-value="gameInput"
:options="fightingGameOptions" :options="fightingGameOptions"
:label="t('scoreboardLabelGame')" :label="t('scoreboardLabelGame')"
@@ -32,10 +78,59 @@ const adjustRightScore = (delta: number) => {
fill-input fill-input
class="scoreboard-preview__field scoreboard-preview__game-field" class="scoreboard-preview__field scoreboard-preview__game-field"
@filter="onGameFilter" @filter="onGameFilter"
@update:model-value="handleGameSelect"
> >
<template #prepend> <template #prepend>
<QIcon name="sports_esports" /> <QIcon name="sports_esports" />
</template> </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> </QSelect>
<div class="scoreboard-preview__score-controls"> <div class="scoreboard-preview__score-controls">
@@ -101,8 +196,25 @@ const adjustRightScore = (delta: number) => {
class="scoreboard-preview__action-btn" class="scoreboard-preview__action-btn"
@click="scoreboardStore.resetScores" @click="scoreboardStore.resetScores"
/> />
</div> </div>
</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> </template>
<style scoped> <style scoped>
@@ -188,4 +300,13 @@ const adjustRightScore = (delta: number) => {
.scoreboard-preview__field :deep(.q-field__label) { .scoreboard-preview__field :deep(.q-field__label) {
color: rgba(255, 255, 255, 0.92); 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> </style>
@@ -1,60 +1,96 @@
// 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 { computed, ref, watch, type InjectionKey, type Ref } from 'vue';
import { getCharactersByGame, getDefaultCharactersByGame } from '../../../shared/fighting-characters'; import { getCharactersByGame, getDefaultCharactersByGame, installedPacksRevision } from '../../../shared/fighting-characters';
import type { GameSelectOption, PackRegistryEntry } from '../../../shared/pack-types';
import { useScoreboardStore } from '../stores/scoreboard'; import { useScoreboardStore } from '../stores/scoreboard';
import { usePackRegistry } from './usePackRegistry';
// --------------------------------------------------------------------------- // ── Types ─────────────────────────────────────────────────────────────────────
// 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]; export type CharacterOption = ReturnType<typeof getCharactersByGame>[number];
// ---------------------------------------------------------------------------
// Injection key (type-safe provide/inject)
// ---------------------------------------------------------------------------
export type CharacterGameContext = ReturnType<typeof useCharacterGame>; export type CharacterGameContext = ReturnType<typeof useCharacterGame>;
export const CHARACTER_GAME_KEY: InjectionKey<CharacterGameContext> = Symbol('characterGame'); 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() { export function useCharacterGame() {
const scoreboardStore = useScoreboardStore(); const scoreboardStore = useScoreboardStore();
const packRegistry = usePackRegistry();
// ── Game selector state ───────────────────────────────────────────────────
// Game selector
const gameInput = ref(''); 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.scoreboard.game = '';
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.scoreboard.game = gameName;
};
// ── Character state ───────────────────────────────────────────────────────
const characterOptions = computed(() => {
// Subscribing to installedPacksRevision forces Vue to re-evaluate this
// computed whenever a pack is registered/unregistered at runtime, even
// though scoreboardStore.scoreboard.game itself hasn't changed.
void installedPacksRevision.value;
return getCharactersByGame(scoreboardStore.scoreboard.game);
});
const leftCharacterOptions = ref<CharacterOption[]>([]); const leftCharacterOptions = ref<CharacterOption[]>([]);
const rightCharacterOptions = ref<CharacterOption[]>([]); const rightCharacterOptions = ref<CharacterOption[]>([]);
const leftCharacterInput = ref(''); const leftCharacterInput = ref('');
const rightCharacterInput = ref(''); const rightCharacterInput = ref('');
// Remembers selected characters per game so swapping games restores them
const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({}); const charactersByGame = ref<Record<string, { leftCharacter: string; rightCharacter: string }>>({});
// Character images for preview
const leftCharacterImage = computed(() => { const leftCharacterImage = computed(() => {
const match = characterOptions.value.find( const match = characterOptions.value.find(
(o) => o.value === scoreboardStore.scoreboard.leftCharacter, (o) => o.value === scoreboardStore.scoreboard.leftCharacter,
@@ -69,20 +105,21 @@ export function useCharacterGame() {
return match?.image ?? ''; return match?.image ?? '';
}); });
// --------------------------------------------------------------------------- // ── Filter handlers ───────────────────────────────────────────────────────
// Filter handlers
// ---------------------------------------------------------------------------
const onGameFilter = (value: string, update: (fn: () => void) => void) => { const onGameFilter = (value: string, update: (fn: () => void) => void) => {
update(() => { update(() => {
const needle = value.toLowerCase().trim(); const needle = value.toLowerCase().trim();
fightingGameOptions.value = needle fightingGameOptions.value = needle
? ALL_FIGHTING_GAME_OPTIONS.filter((g) => g.label.toLowerCase().includes(needle)) ? packRegistry.allGameOptions.value.filter((g) =>
: ALL_FIGHTING_GAME_OPTIONS; g.label.toLowerCase().includes(needle),
)
: packRegistry.allGameOptions.value;
}); });
}; };
const makeCharacterFilter = (target: Ref<CharacterOption[]>) => const makeCharacterFilter =
(target: Ref<CharacterOption[]>) =>
(value: string, update: (fn: () => void) => void) => { (value: string, update: (fn: () => void) => void) => {
update(() => { update(() => {
const needle = value.toLowerCase().trim(); const needle = value.toLowerCase().trim();
@@ -95,16 +132,14 @@ export function useCharacterGame() {
const onLeftCharacterFilter = makeCharacterFilter(leftCharacterOptions); const onLeftCharacterFilter = makeCharacterFilter(leftCharacterOptions);
const onRightCharacterFilter = makeCharacterFilter(rightCharacterOptions); const onRightCharacterFilter = makeCharacterFilter(rightCharacterOptions);
// --------------------------------------------------------------------------- // ── Watchers ──────────────────────────────────────────────────────────────
// Watchers
// ---------------------------------------------------------------------------
// Keep gameInput display value in sync // Keep gameInput display value in sync with the store
watch( watch(
() => scoreboardStore.scoreboard.game, () => scoreboardStore.scoreboard.game,
(value) => { (value) => {
const match = ALL_FIGHTING_GAME_OPTIONS.find((o) => o.value === value); const match = fightingGameOptions.value.find((o) => o.value === value);
gameInput.value = match?.label ?? ''; gameInput.value = match?.label ?? value;
}, },
{ immediate: true }, { immediate: true },
); );
@@ -121,6 +156,13 @@ export function useCharacterGame() {
} }
const options = getCharactersByGame(newGame); const options = getCharactersByGame(newGame);
// If the game is set but has no options yet, the pack is still loading
// (installed pack whose registerInstalledPack() hasn't run yet).
// Bail out — the installedPacksRevision watcher below will restore state
// once the pack becomes available.
if (newGame && options.length === 0) return;
leftCharacterOptions.value = options; leftCharacterOptions.value = options;
rightCharacterOptions.value = options; rightCharacterOptions.value = options;
const allowed = new Set(options.map((o) => o.value)); const allowed = new Set(options.map((o) => o.value));
@@ -133,7 +175,6 @@ export function useCharacterGame() {
if (!allowed.has(nextLeft)) nextLeft = ''; if (!allowed.has(nextLeft)) nextLeft = '';
if (!allowed.has(nextRight)) nextRight = ''; if (!allowed.has(nextRight)) nextRight = '';
// Apply defaults only when neither side had a character yet
if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) { if ((!nextLeft || !nextRight) && (!curLeft || !curRight)) {
const defaults = getDefaultCharactersByGame(newGame); const defaults = getDefaultCharactersByGame(newGame);
if (defaults) { if (defaults) {
@@ -159,7 +200,6 @@ export function useCharacterGame() {
{ immediate: true }, { immediate: true },
); );
// Keep left character display input and charactersByGame cache in sync
watch( watch(
() => scoreboardStore.scoreboard.leftCharacter, () => scoreboardStore.scoreboard.leftCharacter,
(value) => { (value) => {
@@ -176,7 +216,6 @@ export function useCharacterGame() {
{ immediate: true }, { immediate: true },
); );
// Keep right character display input and charactersByGame cache in sync
watch( watch(
() => scoreboardStore.scoreboard.rightCharacter, () => scoreboardStore.scoreboard.rightCharacter,
(value) => { (value) => {
@@ -193,16 +232,55 @@ export function useCharacterGame() {
{ immediate: true }, { immediate: true },
); );
// When an installed pack becomes available (e.g. after page refresh while
// the pack loads asynchronously), re-validate and restore the characters
// that are already in the store but couldn't be confirmed before.
watch(installedPacksRevision, () => {
const game = scoreboardStore.scoreboard.game;
if (!game) return;
const options = getCharactersByGame(game);
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.scoreboard.leftCharacter = '';
leftCharacterInput.value = '';
}
if (rightCharacter && allowed.has(rightCharacter)) {
rightCharacterInput.value = options.find((o) => o.value === rightCharacter)?.label ?? '';
} else if (rightCharacter && !allowed.has(rightCharacter)) {
scoreboardStore.scoreboard.rightCharacter = '';
rightCharacterInput.value = '';
}
});
// ── Return ────────────────────────────────────────────────────────────────
return { return {
// Game selector
gameInput, gameInput,
fightingGameOptions, fightingGameOptions,
onGameFilter,
handleGameSelect,
// Download dialog
pendingDownloadEntry,
showDownloadDialog,
// Character state
leftCharacterOptions, leftCharacterOptions,
rightCharacterOptions, rightCharacterOptions,
leftCharacterInput, leftCharacterInput,
rightCharacterInput, rightCharacterInput,
leftCharacterImage, leftCharacterImage,
rightCharacterImage, rightCharacterImage,
onGameFilter,
onLeftCharacterFilter, onLeftCharacterFilter,
onRightCharacterFilter, onRightCharacterFilter,
}; };
@@ -0,0 +1,266 @@
// src/dashboard/scoreboard/composables/usePackRegistry.ts
// ─────────────────────────────────────────────────────────────────────────────
// Singleton composable. The first caller sets up NodeCG replicant listeners;
// subsequent calls return the same reactive state. This avoids duplicate event
// listeners when multiple components call usePackRegistry().
// ─────────────────────────────────────────────────────────────────────────────
import { computed, ref, type ComputedRef, type InjectionKey } from 'vue';
import {
registerInstalledPack,
unregisterInstalledPack,
} from '../../../shared/fighting-characters';
import { BUNDLE_NAME } from '../../../shared/pack-config';
import type {
GameSelectOption,
PackDownloadState,
PackManifest,
PackRegistry
} from '../../../shared/pack-types';
// ── NodeCG global type declarations ──────────────────────────────────────────
// NodeCG injects these into the browser window via its bundle script.
declare const NodeCG: {
Replicant: <T>(
name: string,
bundleName: string,
opts?: { defaultValue?: T },
) => {
value: T;
on(event: 'change', handler: (newVal: T, oldVal?: T) => void): void;
off(event: string, handler: (...args: unknown[]) => void): void;
};
waitForReplicants: (...reps: unknown[]) => Promise<void>;
};
declare const nodecg: {
sendMessage(name: string, data?: unknown): void;
sendMessage(
name: string,
data: unknown,
cb: (err: Error | null, result?: unknown) => void,
): void;
};
// ── Module-level singleton state ──────────────────────────────────────────────
let initialized = false;
const registry = ref<PackRegistry | null>(null);
const installedPackIds = ref<string[]>([]);
const downloadStates = ref<Record<string, PackDownloadState>>({});
const availableUpdates = ref<Record<string, { installedVersion: string; latestVersion: string }>>({});
// Tracks which installed pack manifests have been loaded into fighting-characters.ts
const loadedManifestIds = new Set<string>();
// ── Helpers ───────────────────────────────────────────────────────────────────
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`;
};
/**
* Asks the NodeCG extension to read the local manifest.json for an installed
* pack and registers the characters in fighting-characters.ts.
*/
const loadInstalledManifest = (packId: string): void => {
if (loadedManifestIds.has(packId)) return;
nodecg.sendMessage('readLocalManifest', packId, (err, result) => {
if (err) {
console.error(`[usePackRegistry] Failed to load manifest for "${packId}":`, err);
return;
}
const manifest = result as PackManifest;
registerInstalledPack(manifest);
loadedManifestIds.add(packId);
});
};
// ── Replicant setup (runs once) ───────────────────────────────────────────────
const initReplicants = (): void => {
if (initialized) return;
initialized = true;
const registryRep = NodeCG.Replicant<PackRegistry | null>('packRegistry', BUNDLE_NAME, {
defaultValue: null,
});
const installedRep = NodeCG.Replicant<string[]>('installedPacks', BUNDLE_NAME, {
defaultValue: [],
});
const statesRep = NodeCG.Replicant<Record<string, PackDownloadState>>('downloadStates', BUNDLE_NAME, {
defaultValue: {},
});
const updatesRep = NodeCG.Replicant<Record<string, { installedVersion: string; latestVersion: string }>>('availableUpdates', BUNDLE_NAME, {
defaultValue: {},
});
NodeCG.waitForReplicants(registryRep, installedRep, statesRep, updatesRep).then(() => {
// Hydrate initial values
registry.value = registryRep.value;
installedPackIds.value = installedRep.value ?? [];
downloadStates.value = statesRep.value ?? {};
availableUpdates.value = updatesRep.value ?? {};
// Load manifests for all installed packs
for (const id of installedPackIds.value) {
loadInstalledManifest(id);
}
// Subscribe to changes
registryRep.on('change', (val) => {
registry.value = val;
});
installedRep.on('change', (newVal, oldVal) => {
const next = newVal ?? [];
const prev = oldVal ?? [];
installedPackIds.value = next;
// Load manifests for newly installed packs
const added = next.filter((id) => !prev.includes(id));
for (const id of added) {
loadInstalledManifest(id);
}
// Unregister packs that were removed
const removed = prev.filter((id) => !next.includes(id));
for (const id of removed) {
const gameName = getGameNameById(id);
unregisterInstalledPack(gameName);
loadedManifestIds.delete(id);
}
});
statesRep.on('change', (val) => {
downloadStates.value = val ?? {};
});
updatesRep.on('change', (val) => {
availableUpdates.value = val ?? {};
});
});
};
/**
* Given a pack ID (e.g. "street-fighter-6"), returns the matching game name
* from the current registry, or an empty string if the registry isn't loaded.
*/
const getGameNameById = (packId: string): string =>
registry.value?.packs.find((p) => p.id === packId)?.name ?? '';
// ── Public composable ─────────────────────────────────────────────────────────
export interface PackRegistryContext {
/** Full registry fetched from Gitea (null until first fetch). */
registry: typeof registry;
/** IDs of packs installed on disk (bundled packs are NOT in this list). */
installedPackIds: typeof installedPackIds;
/** Per-pack download state. */
downloadStates: typeof downloadStates;
/** Checks if a game is available (bundled OR installed). */
isGameAvailable: (gameName: string) => boolean;
/** Returns the download state for a pack, or a default idle state. */
getDownloadState: (packId: string) => PackDownloadState;
/** All games from the registry, enriched with availability info. */
allGameOptions: ReturnType<typeof buildAllGameOptions>;
/** Tells the extension to fetch the latest registry.json from Gitea. */
fetchRegistry: () => void;
/** Tells the extension to download and install a pack. */
downloadPack: (packId: string) => void;
/** Tells the extension to uninstall a pack and delete its files. */
uninstallPack: (packId: string) => void;
/** Tells the extension to download and apply an update for an installed pack. */
updatePack: (packId: string) => void;
/** Map of packId → version info for packs that have a newer version available. */
availableUpdates: typeof availableUpdates;
/** Total number of packs with available updates. */
updateCount: ComputedRef<number>;
/** Human-readable file size. */
formatBytes: typeof formatBytes;
/** Returns the URL for the pack's logo served by NodeCG (installed packs only). */
getLocalLogoUrl: (packId: string) => string;
}
export const PACK_REGISTRY_KEY: InjectionKey<PackRegistryContext> = Symbol('packRegistry');
const buildAllGameOptions = () =>
computed<GameSelectOption[]>(() => {
// Registry not loaded yet — return empty list
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],
}));
});
export function usePackRegistry(): PackRegistryContext {
initReplicants();
const allGameOptions = buildAllGameOptions();
const isGameAvailable = (gameName: string): boolean => {
const entry = registry.value?.packs.find((p) => p.name === gameName);
if (!entry) return false;
return installedPackIds.value.includes(entry.id);
};
const getDownloadState = (packId: string): PackDownloadState =>
downloadStates.value[packId] ?? { status: 'idle', progress: 0 };
const getLocalLogoUrl = (packId: string): string =>
`/packs/${packId}/logo.png`;
const fetchRegistry = (): void => {
nodecg.sendMessage('fetchPackRegistry', undefined, (err) => {
if (err) console.error('[usePackRegistry] fetchPackRegistry failed:', err);
});
};
const downloadPack = (packId: string): void => {
nodecg.sendMessage('downloadPack', packId, (err) => {
if (err) console.error(`[usePackRegistry] downloadPack "${packId}" failed:`, err);
});
};
const uninstallPack = (packId: string): void => {
nodecg.sendMessage('uninstallPack', packId, (err) => {
if (err) console.error(`[usePackRegistry] uninstallPack "${packId}" failed:`, err);
});
};
const updatePack = (packId: string): void => {
nodecg.sendMessage('updatePack', packId, (err) => {
if (err) console.error(`[usePackRegistry] updatePack "${packId}" failed:`, err);
});
};
const updateCount = computed(() => Object.keys(availableUpdates.value).length);
return {
registry,
installedPackIds,
downloadStates,
isGameAvailable,
getDownloadState,
allGameOptions,
fetchRegistry,
downloadPack,
uninstallPack,
updatePack,
availableUpdates,
updateCount,
formatBytes,
getLocalLogoUrl,
};
}
+130 -83
View File
@@ -24,6 +24,14 @@ type Translations = {
settingsShortcutRightDecrementHint: string; settingsShortcutRightDecrementHint: string;
settingsShortcutReset: string; settingsShortcutReset: string;
settingsShortcutRecordingHint: string; settingsShortcutRecordingHint: string;
settingsShortcutConflictWarning: string;
settingsShortcutStartRecording: string;
settingsShortcutStopRecording: string;
settingsShortcutResetSingle: string;
settingsIntegrationsTitle: string;
settingsIntegrationsDescription: string;
settingsDisconnect: string;
settingsNotConnected: string;
languageEnglish: string; languageEnglish: string;
languageSpanish: string; languageSpanish: string;
scoreboardUnassigned: string; scoreboardUnassigned: string;
@@ -53,6 +61,8 @@ type Translations = {
aboutElectronNote: string; aboutElectronNote: string;
aboutUnknownReleaseError: string; aboutUnknownReleaseError: string;
aboutGitHubStatusError: string; aboutGitHubStatusError: string;
aboutChangelog: string;
aboutTechStackTitle: string;
graphicsTitle: string; graphicsTitle: string;
graphicsDescription: string; graphicsDescription: string;
graphicsNoConfigured: string; graphicsNoConfigured: string;
@@ -61,10 +71,16 @@ type Translations = {
graphicsScoreboard: string; graphicsScoreboard: string;
graphicsCommentary: string; graphicsCommentary: string;
graphicsSkinLabel: string; graphicsSkinLabel: string;
graphicsCopied: string;
graphicsOpenBrowser: string;
commentaryTitle: string; commentaryTitle: string;
commentaryCommentator1: string; commentaryCommentator1: string;
commentaryCommentator2: string; commentaryCommentator2: string;
commentaryTwitterText: string; commentaryTwitterText: string;
commentaryTwitterMaxLength: string;
commentaryTwitterInvalidChars: string;
commentarySwap: string;
commentaryClear: string;
bracketTitle: string; bracketTitle: string;
bracketStage: string; bracketStage: string;
bracketSide: string; bracketSide: string;
@@ -85,18 +101,8 @@ type Translations = {
playersSearchPlaceholder: string; playersSearchPlaceholder: string;
playersImport: string; playersImport: string;
playersExport: string; playersExport: string;
commentaryTwitterMaxLength: string; playersConnectInSettings: string;
commentaryTwitterInvalidChars: string; playersConnectInSettingsSuffix: string;
commentarySwap: string;
commentaryClear: string;
aboutChangelog : string;
aboutTechStackTitle : string;
settingsShortcutConflictWarning : string;
settingsShortcutStartRecording: string;
settingsShortcutStopRecording: string;
settingsShortcutResetSingle: string;
graphicsCopied : string;
graphicsOpenBrowser : string;
}; };
const STORAGE_KEY = 'scoreko-dev.language'; const STORAGE_KEY = 'scoreko-dev.language';
@@ -108,28 +114,42 @@ const messages: Record<Locale, Translations> = {
menuGraphics: 'Graphics', menuGraphics: 'Graphics',
menuSettings: 'Settings', menuSettings: 'Settings',
menuAbout: 'About', menuAbout: 'About',
// ── Settings ────────────────────────────────────────────────────────────
settingsTitle: 'Settings', settingsTitle: 'Settings',
settingsDescription: 'Dashboard and bundle configuration.', settingsDescription: 'Dashboard and bundle settings.',
settingsLanguageLabel: 'Language', settingsLanguageLabel: 'Language',
settingsLanguageHint: 'Choose the dashboard language.', settingsLanguageHint: 'Choose the dashboard language.',
settingsShortcutTitle: 'Keyboard shortcuts', settingsShortcutTitle: 'Keyboard shortcuts',
settingsShortcutDescription: 'Configure quick keys to update the score for each side.', settingsShortcutDescription: 'Configure keyboard shortcuts to update each sides score.',
settingsShortcutLeftIncrementLabel: 'P1 score +1', settingsShortcutLeftIncrementLabel: 'P1 score +1',
settingsShortcutLeftIncrementHint: 'Increases left player score by one.', settingsShortcutLeftIncrementHint: 'Increases the left players score by one.',
settingsShortcutLeftDecrementLabel: 'P1 score -1', settingsShortcutLeftDecrementLabel: 'P1 score -1',
settingsShortcutLeftDecrementHint: 'Decreases left player score by one.', settingsShortcutLeftDecrementHint: 'Decreases the left players score by one.',
settingsShortcutRightIncrementLabel: 'P2 score +1', settingsShortcutRightIncrementLabel: 'P2 score +1',
settingsShortcutRightIncrementHint: 'Increases right player score by one.', settingsShortcutRightIncrementHint: 'Increases the right players score by one.',
settingsShortcutRightDecrementLabel: 'P2 score -1', settingsShortcutRightDecrementLabel: 'P2 score -1',
settingsShortcutRightDecrementHint: 'Decreases right player score by one.', settingsShortcutRightDecrementHint: 'Decreases the right players score by one.',
settingsShortcutReset: 'Reset shortcuts', settingsShortcutReset: 'Reset shortcuts',
settingsShortcutRecordingHint: 'Press the desired shortcut now (example: Alt+1).', settingsShortcutRecordingHint: 'Press the desired shortcut now (for example: Alt+1).',
settingsShortcutConflictWarning: 'This shortcut is already assigned to another action.',
settingsShortcutStartRecording: 'Start recording shortcut',
settingsShortcutStopRecording: 'Stop recording shortcut',
settingsShortcutResetSingle: 'Reset this shortcut',
settingsIntegrationsTitle: 'Integrations',
settingsIntegrationsDescription: 'Connect your tournament platform accounts to import players directly from brackets.',
settingsDisconnect: 'Disconnect',
settingsNotConnected: 'Not connected',
// ── Language ─────────────────────────────────────────────────────────────
languageEnglish: 'English', languageEnglish: 'English',
languageSpanish: 'Spanish', languageSpanish: 'Spanish',
// ── Scoreboard ───────────────────────────────────────────────────────────
scoreboardUnassigned: '(Unassigned)', scoreboardUnassigned: '(Unassigned)',
scoreboardLeft: 'Left', scoreboardLeft: 'Left',
scoreboardRight: 'Right', scoreboardRight: 'Right',
scoreboardPreview: 'preview', scoreboardPreview: 'Preview',
scoreboardLeftImage: 'Left image', scoreboardLeftImage: 'Left image',
scoreboardRightImage: 'Right image', scoreboardRightImage: 'Right image',
scoreboardLabelCharacter: 'Character', scoreboardLabelCharacter: 'Character',
@@ -137,11 +157,13 @@ const messages: Record<Locale, Translations> = {
scoreboardLabelTeam: 'Team', scoreboardLabelTeam: 'Team',
scoreboardLabelCountry: 'Country', scoreboardLabelCountry: 'Country',
scoreboardLabelGame: 'Game', scoreboardLabelGame: 'Game',
// ── About ────────────────────────────────────────────────────────────────
aboutTitle: 'About', aboutTitle: 'About',
aboutVersion: 'Version', aboutVersion: 'Version',
aboutDescription: 'Dashboard for producing fighting game overlays using NodeCG, Vue, and Quasar.', aboutDescription: 'Dashboard for producing fighting game overlays with NodeCG, Vue, and Quasar.',
aboutFrameworkNodeCG: 'Framework NodeCG', aboutFrameworkNodeCG: 'NodeCG framework',
aboutCollaboratorsTitle: 'Collaborators and acknowledgments', aboutCollaboratorsTitle: 'Contributors and acknowledgments',
aboutUpdateSystemTitle: 'Update system (GitHub Releases)', aboutUpdateSystemTitle: 'Update system (GitHub Releases)',
aboutUpdateSystemDescription: 'This check fetches the latest release from the repository and compares it with the current version.', aboutUpdateSystemDescription: 'This check fetches the latest release from the repository and compares it with the current version.',
aboutCheckUpdates: 'Check for updates', aboutCheckUpdates: 'Check for updates',
@@ -150,33 +172,49 @@ const messages: Record<Locale, Translations> = {
aboutUpdateAvailable: 'A newer version is available.', aboutUpdateAvailable: 'A newer version is available.',
aboutUpToDate: 'Your version is up to date with the latest release.', aboutUpToDate: 'Your version is up to date with the latest release.',
aboutViewRelease: 'View release', aboutViewRelease: 'View release',
aboutElectronNote: 'Note for Electron: this panel only implements detection and notification. For real automatic desktop updates, you need to integrate autoUpdater into Electron\'s main process and publish signed artifacts per platform.', aboutElectronNote: 'Note for Electron: this panel only implements detection and notification. For real automatic desktop updates, you need to integrate autoUpdater into Electrons main process and publish signed artifacts per platform.',
aboutUnknownReleaseError: 'Unknown error while checking releases.', aboutUnknownReleaseError: 'Unknown error while checking releases.',
aboutGitHubStatusError: 'GitHub responded with status', aboutGitHubStatusError: 'GitHub responded with status',
aboutChangelog: 'Changelog',
aboutTechStackTitle: 'Tech stack',
// ── Graphics ─────────────────────────────────────────────────────────────
graphicsTitle: 'Graphics', graphicsTitle: 'Graphics',
graphicsDescription: 'Bundle graphics controls and status.', graphicsDescription: 'Controls and status for bundle graphics.',
graphicsNoConfigured: 'There are no graphics configured in this bundle.', graphicsNoConfigured: 'There are no graphics configured in this bundle.',
graphicsCopyUrl: 'Copy URL', graphicsCopyUrl: 'Copy URL',
graphicsDragObs: 'Drag into OBS', graphicsDragObs: 'Drag into OBS',
graphicsScoreboard: 'Scoreboard', graphicsScoreboard: 'Scoreboard',
graphicsCommentary: 'Commentary', graphicsCommentary: 'Commentators',
graphicsSkinLabel: 'Skin', graphicsSkinLabel: 'Theme',
commentaryTitle: 'Commentary', graphicsCopied: 'URL copied to clipboard',
graphicsOpenBrowser: 'Open in browser',
// ── Commentary ───────────────────────────────────────────────────────────
commentaryTitle: 'Commentators',
commentaryCommentator1: 'Commentator #1', commentaryCommentator1: 'Commentator #1',
commentaryCommentator2: 'Commentator #2', commentaryCommentator2: 'Commentator #2',
commentaryTwitterText: '@Twitter / Text', commentaryTwitterText: 'Twitter / Text',
commentaryTwitterMaxLength: 'Twitter character limit exceeded',
commentaryTwitterInvalidChars: 'Invalid characters in Twitter text',
commentarySwap: 'Swap commentators',
commentaryClear: 'Clear commentators',
// ── Bracket ──────────────────────────────────────────────────────────────
bracketTitle: 'Bracket', bracketTitle: 'Bracket',
bracketStage: 'Stage', bracketStage: 'Stage',
bracketSide: 'Bracket side', bracketSide: 'Bracket side',
bracketCustomProgress: 'Custom progress', bracketCustomProgress: 'Custom progress',
bracketPreview: 'Preview', bracketPreview: 'Preview',
// ── Players ──────────────────────────────────────────────────────────────
playersLabelTeam: 'Team', playersLabelTeam: 'Team',
playersLabelCountry: 'Country', playersLabelCountry: 'Country',
playersLabelActions: 'Actions', playersLabelActions: 'Actions',
playersStartggHelp: 'Connect via OAuth (recommended) or paste your personal token to load tournaments you created or administrate. If you see "Client authentication failed", verify your config uses the Client ID/Secret from a start.gg OAuth App.', playersStartggHelp: 'Connect via OAuth (recommended) or paste your personal token to load tournaments you created or manage.',
playersConnectStartgg: 'Connect with start.gg', playersConnectStartgg: 'Connect with start.gg',
playersConnected: 'Connected', playersConnected: 'Connected',
playersUsePersonalApi: 'Use personal API', playersUsePersonalApi: 'Use personal token',
playersTournament: 'Tournament', playersTournament: 'Tournament',
playersImportPlayers: 'Import players', playersImportPlayers: 'Import players',
playersChallongeHelp: 'Connect with OAuth or paste your personal token to load your Challonge tournaments and import participants.', playersChallongeHelp: 'Connect with OAuth or paste your personal token to load your Challonge tournaments and import participants.',
@@ -185,43 +223,48 @@ const messages: Record<Locale, Translations> = {
playersSearchPlaceholder: 'Search...', playersSearchPlaceholder: 'Search...',
playersImport: 'Import', playersImport: 'Import',
playersExport: 'Export', playersExport: 'Export',
commentaryTwitterMaxLength: 'Twitter character limit exceeded', playersConnectInSettings: 'Connect your account in',
commentaryTwitterInvalidChars: 'Invalid characters in Twitter text', playersConnectInSettingsSuffix: 'to import players from tournaments.',
commentarySwap: 'Swap commentators',
commentaryClear: 'Clear commentary',
aboutChangelog: 'Changelog',
aboutTechStackTitle: 'Tech stack',
settingsShortcutConflictWarning: 'This shortcut is already assigned to',
settingsShortcutStartRecording: 'Start recording shortcut',
settingsShortcutStopRecording: 'Stop recording shortcut',
settingsShortcutResetSingle: 'Reset single player score shortcut',
graphicsCopied: 'URL copied to clipboard',
graphicsOpenBrowser: 'Open in browser',
}, },
es: { es: {
menuDashboard: 'Panel', menuDashboard: 'Panel',
menuPlayers: 'Jugadores', menuPlayers: 'Jugadores',
menuGraphics: 'Gráficos', menuGraphics: 'Gráficos',
menuSettings: 'Configuración', menuSettings: 'Configuración',
menuAbout: 'Acerca de', menuAbout: 'Acerca de',
// ── Settings ────────────────────────────────────────────────────────────
settingsTitle: 'Configuración', settingsTitle: 'Configuración',
settingsDescription: 'Configuración del dashboard y del bundle.', settingsDescription: 'Configuración del panel y del bundle.',
settingsLanguageLabel: 'Idioma', settingsLanguageLabel: 'Idioma',
settingsLanguageHint: 'Selecciona el idioma del dashboard.', settingsLanguageHint: 'Selecciona el idioma del dashboard.',
settingsShortcutTitle: 'Atajos de teclado', settingsShortcutTitle: 'Atajos de teclado',
settingsShortcutDescription: 'Configura teclas rápidas para actualizar el score de cada lado.', settingsShortcutDescription: 'Configura atajos para actualizar el marcador de cada lado.',
settingsShortcutLeftIncrementLabel: 'Score P1 +1', settingsShortcutLeftIncrementLabel: 'Marcador P1 +1',
settingsShortcutLeftIncrementHint: 'Incrementa en uno el score del jugador izquierdo.', settingsShortcutLeftIncrementHint: 'Incrementa en uno el marcador del jugador izquierdo.',
settingsShortcutLeftDecrementLabel: 'Score P1 -1', settingsShortcutLeftDecrementLabel: 'Marcador P1 -1',
settingsShortcutLeftDecrementHint: 'Reduce en uno el score del jugador izquierdo.', settingsShortcutLeftDecrementHint: 'Reduce en uno el marcador del jugador izquierdo.',
settingsShortcutRightIncrementLabel: 'Score P2 +1', settingsShortcutRightIncrementLabel: 'Marcador P2 +1',
settingsShortcutRightIncrementHint: 'Incrementa en uno el score del jugador derecho.', settingsShortcutRightIncrementHint: 'Incrementa en uno el marcador del jugador derecho.',
settingsShortcutRightDecrementLabel: 'Score P2 -1', settingsShortcutRightDecrementLabel: 'Marcador P2 -1',
settingsShortcutRightDecrementHint: 'Reduce en uno el score del jugador derecho.', settingsShortcutRightDecrementHint: 'Reduce en uno el marcador del jugador derecho.',
settingsShortcutReset: 'Restablecer atajos', settingsShortcutReset: 'Restablecer atajos',
settingsShortcutRecordingHint: 'Pulsa ahora el atajo deseado (ejemplo: Alt+1).', settingsShortcutRecordingHint: 'Pulsa ahora el atajo deseado (ejemplo: Alt+1).',
settingsShortcutConflictWarning: 'Este atajo ya está asignado a otra acción.',
settingsShortcutStartRecording: 'Iniciar grabación de atajo',
settingsShortcutStopRecording: 'Detener grabación de atajo',
settingsShortcutResetSingle: 'Restablecer este atajo',
settingsIntegrationsTitle: 'Integraciones',
settingsIntegrationsDescription: 'Conecta tus cuentas de plataformas de torneos para importar jugadores directamente desde los brackets.',
settingsDisconnect: 'Desconectar',
settingsNotConnected: 'No conectado',
// ── Language ─────────────────────────────────────────────────────────────
languageEnglish: 'Inglés', languageEnglish: 'Inglés',
languageSpanish: 'Castellano', languageSpanish: 'Español',
// ── Scoreboard ───────────────────────────────────────────────────────────
scoreboardUnassigned: '(Sin asignar)', scoreboardUnassigned: '(Sin asignar)',
scoreboardLeft: 'Izquierda', scoreboardLeft: 'Izquierda',
scoreboardRight: 'Derecha', scoreboardRight: 'Derecha',
@@ -233,43 +276,61 @@ const messages: Record<Locale, Translations> = {
scoreboardLabelTeam: 'Equipo', scoreboardLabelTeam: 'Equipo',
scoreboardLabelCountry: 'País', scoreboardLabelCountry: 'País',
scoreboardLabelGame: 'Juego', scoreboardLabelGame: 'Juego',
// ── About ────────────────────────────────────────────────────────────────
aboutTitle: 'Acerca de', aboutTitle: 'Acerca de',
aboutVersion: 'Versión', aboutVersion: 'Versión',
aboutDescription: 'Dashboard para producir overlays de juegos de lucha usando NodeCG, Vue y Quasar.', aboutDescription: 'Panel para producir overlays de juegos de lucha usando NodeCG, Vue y Quasar.',
aboutFrameworkNodeCG: 'Framework NodeCG', aboutFrameworkNodeCG: 'Framework NodeCG',
aboutCollaboratorsTitle: 'Colaboradores y agradecimientos', aboutCollaboratorsTitle: 'Colaboradores y agradecimientos',
aboutUpdateSystemTitle: 'Sistema de actualizaciones (GitHub Releases)', aboutUpdateSystemTitle: 'Sistema de actualizaciones (GitHub Releases)',
aboutUpdateSystemDescription: 'Esta comprobación obtiene la última release del repositorio y la compara con la versión actual.', aboutUpdateSystemDescription: 'Esta comprobación obtiene la última versión publicada del repositorio y la compara con la versión actual.',
aboutCheckUpdates: 'Buscar actualizaciones', aboutCheckUpdates: 'Buscar actualizaciones',
aboutLatestRelease: 'Última release', aboutLatestRelease: 'Última versión',
aboutPublished: 'Publicado', aboutPublished: 'Publicado',
aboutUpdateAvailable: 'Hay una versión más nueva disponible.', aboutUpdateAvailable: 'Hay una versión más nueva disponible.',
aboutUpToDate: 'Tu versión está actualizada con la última release.', aboutUpToDate: 'Tu versión está actualizada con la última versión.',
aboutViewRelease: 'Ver release', aboutViewRelease: 'Ver versión',
aboutElectronNote: 'Nota para Electron: este panel solo implementa detección y notificación. Para actualizaciones automáticas reales de escritorio, debes integrar autoUpdater en el proceso principal de Electron y publicar artefactos firmados por plataforma.', aboutElectronNote: 'Nota para Electron: este panel solo implementa detección y notificación. Para actualizaciones automáticas reales de escritorio, debes integrar autoUpdater en el proceso principal de Electron y publicar artefactos firmados por plataforma.',
aboutUnknownReleaseError: 'Error desconocido al consultar releases.', aboutUnknownReleaseError: 'Error desconocido al consultar releases.',
aboutGitHubStatusError: 'GitHub respondió con estado', aboutGitHubStatusError: 'GitHub respondió con estado',
aboutChangelog: 'Registro de cambios',
aboutTechStackTitle: 'Stack tecnológico',
// ── Graphics ─────────────────────────────────────────────────────────────
graphicsTitle: 'Gráficos', graphicsTitle: 'Gráficos',
graphicsDescription: 'Controles y estado de los gráficos del bundle.', graphicsDescription: 'Controles y estado de los gráficos del bundle.',
graphicsNoConfigured: 'No hay gráficos configurados en este bundle.', graphicsNoConfigured: 'No hay gráficos configurados en este bundle.',
graphicsCopyUrl: 'Copiar URL', graphicsCopyUrl: 'Copiar URL',
graphicsDragObs: 'Arrastrar a OBS', graphicsDragObs: 'Arrastrar a OBS',
graphicsScoreboard: 'Scoreboard', graphicsScoreboard: 'Marcador',
graphicsCommentary: 'Comentario', graphicsCommentary: 'Comentaristas',
graphicsSkinLabel: 'Skin', graphicsSkinLabel: 'Tema',
commentaryTitle: 'Comentario', graphicsCopied: 'URL copiada al portapapeles',
graphicsOpenBrowser: 'Abrir en el navegador',
// ── Commentary ───────────────────────────────────────────────────────────
commentaryTitle: 'Comentaristas',
commentaryCommentator1: 'Comentarista #1', commentaryCommentator1: 'Comentarista #1',
commentaryCommentator2: 'Comentarista #2', commentaryCommentator2: 'Comentarista #2',
commentaryTwitterText: '@Twitter / Texto', commentaryTwitterText: '@Twitter / Texto',
bracketTitle: 'Bracket', commentaryTwitterMaxLength: 'Se excedió el límite de caracteres de Twitter',
commentaryTwitterInvalidChars: 'Caracteres inválidos en el texto de Twitter',
commentarySwap: 'Intercambiar comentaristas',
commentaryClear: 'Limpiar comentaristas',
// ── Bracket ──────────────────────────────────────────────────────────────
bracketTitle: 'Llave',
bracketStage: 'Etapa', bracketStage: 'Etapa',
bracketSide: 'Lado del bracket', bracketSide: 'Lado de la llave',
bracketCustomProgress: 'Progreso personalizado', bracketCustomProgress: 'Progreso personalizado',
bracketPreview: 'Vista previa', bracketPreview: 'Vista previa',
// ── Players ──────────────────────────────────────────────────────────────
playersLabelTeam: 'Equipo', playersLabelTeam: 'Equipo',
playersLabelCountry: 'País', playersLabelCountry: 'País',
playersLabelActions: 'Acciones', playersLabelActions: 'Acciones',
playersStartggHelp: 'Conéctate por OAuth (recomendado) o pega tu token personal para cargar torneos que creaste o administras. Si ves "Client authentication failed", revisa que tu configuración use el Client ID/Secret de una app OAuth de start.gg.', playersStartggHelp: 'Conéctate por OAuth (recomendado) o pega tu token personal para cargar torneos que creaste o administras.',
playersConnectStartgg: 'Conectar con start.gg', playersConnectStartgg: 'Conectar con start.gg',
playersConnected: 'Conectado', playersConnected: 'Conectado',
playersUsePersonalApi: 'Usar API personal', playersUsePersonalApi: 'Usar API personal',
@@ -281,28 +342,15 @@ const messages: Record<Locale, Translations> = {
playersSearchPlaceholder: 'Buscar...', playersSearchPlaceholder: 'Buscar...',
playersImport: 'Importar', playersImport: 'Importar',
playersExport: 'Exportar', playersExport: 'Exportar',
commentaryTwitterMaxLength: 'Se excedió el límite de caracteres de Twitter', playersConnectInSettings: 'Conecta tu cuenta en',
commentaryTwitterInvalidChars: 'Caracteres inválidos en el texto de Twitter', playersConnectInSettingsSuffix: 'para importar jugadores desde torneos.',
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',
}, },
}; };
const normalizeLocale = (value: unknown): Locale => (value === 'es' ? 'es' : 'en'); const normalizeLocale = (value: unknown): Locale => (value === 'es' ? 'es' : 'en');
const getStoredLocale = (): Locale => { const getStoredLocale = (): Locale => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') return 'en';
return 'en';
}
return normalizeLocale(localStorage.getItem(STORAGE_KEY)); return normalizeLocale(localStorage.getItem(STORAGE_KEY));
}; };
@@ -310,7 +358,6 @@ export const locale = ref<Locale>(getStoredLocale());
export const setLocale = (value: Locale) => { export const setLocale = (value: Locale) => {
locale.value = normalizeLocale(value); locale.value = normalizeLocale(value);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, locale.value); localStorage.setItem(STORAGE_KEY, locale.value);
} }
+315 -79
View File
@@ -1,67 +1,87 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { t } from './i18n'; import { t } from './i18n';
import { useScoreboardStore } from './stores/scoreboard'; import { useScoreboardStore } from './stores/scoreboard';
import { isShortcutMatch, useShortcutSettingsStore } from './stores/shortcut-settings'; import { isShortcutMatch, useShortcutSettingsStore } from './stores/shortcut-settings';
const menuItems = computed(() => [ // ── Sidebar collapse ──────────────────────────────────────────────────────────
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' }, const LS_KEY = 'sidebar_collapsed';
{ label: t('menuPlayers'), to: '/players', icon: 'groups' }, const isCollapsed = ref(localStorage.getItem(LS_KEY) === 'true');
{ label: t('menuGraphics'), to: '/graphics', icon: 'collections' }, const drawerWidth = computed(() => (isCollapsed.value ? 60 : 220));
watch(isCollapsed, (val) => localStorage.setItem(LS_KEY, String(val)));
const toggleCollapse = () => { isCollapsed.value = !isCollapsed.value; };
// ── Version ───────────────────────────────────────────────────────────────────
const appVersion = import.meta.env.PACKAGE_VERSION as string | undefined;
// ── Logo ──────────────────────────────────────────────────────────────────────
const logoUrl = new URL('./image.png', import.meta.url).href;
// ── Menu groups ───────────────────────────────────────────────────────────────
const mainItems = computed(() => [
{ label: t('menuDashboard'), to: '/', icon: 'dashboard' },
{ label: t('menuPlayers'), to: '/players', icon: 'groups' },
{ label: t('menuGraphics'), to: '/graphics', icon: 'collections' },
]);
const configItems = computed(() => [
{ label: t('menuSettings'), to: '/settings', icon: 'settings' }, { label: t('menuSettings'), to: '/settings', icon: 'settings' },
{ label: t('menuAbout'), to: '/about', icon: 'info' }, { label: t('menuAbout'), to: '/about', icon: 'info' },
]); ]);
const logoUrl = new URL('./image.png', import.meta.url).href; // ── Online / Offline ──────────────────────────────────────────────────────────
const scoreboardStore = useScoreboardStore(); const isOnline = ref(navigator.onLine);
const checkOnline = async () => {
try {
await fetch('https://www.google.com/favicon.ico', {
method: 'HEAD',
mode: 'no-cors',
cache: 'no-store',
});
isOnline.value = true;
} catch {
isOnline.value = false;
}
};
const onNetworkOnline = () => { isOnline.value = true; };
const onNetworkOffline = () => { isOnline.value = false; };
let pingInterval: ReturnType<typeof setInterval> | null = null;
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
const scoreboardStore = useScoreboardStore();
const shortcutSettingsStore = useShortcutSettingsStore(); const shortcutSettingsStore = useShortcutSettingsStore();
const isEditableTarget = (target: EventTarget | null): boolean => { const isEditableTarget = (target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) { if (!(target instanceof HTMLElement)) return false;
return false; return (
} target.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) ||
return target.isContentEditable Boolean(target.closest('[contenteditable="true"]'))
|| ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) );
|| Boolean(target.closest('[contenteditable="true"]'));
}; };
const onShortcutPress = (event: KeyboardEvent) => { const onShortcutPress = (event: KeyboardEvent) => {
if (isEditableTarget(event.target) || document.body.dataset.shortcutRecording === 'true') { if (isEditableTarget(event.target) || document.body.dataset.shortcutRecording === 'true') return;
return;
}
const { shortcuts } = shortcutSettingsStore; const { shortcuts } = shortcutSettingsStore;
if (isShortcutMatch(event, shortcuts.leftIncrement)) { if (isShortcutMatch(event, shortcuts.leftIncrement)) { scoreboardStore.leftScore += 1; event.preventDefault(); return; }
scoreboardStore.leftScore += 1; if (isShortcutMatch(event, shortcuts.leftDecrement)) { scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore - 1); event.preventDefault(); return; }
event.preventDefault(); if (isShortcutMatch(event, shortcuts.rightIncrement)) { scoreboardStore.rightScore += 1; event.preventDefault(); return; }
return; if (isShortcutMatch(event, shortcuts.rightDecrement)) { scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore - 1); event.preventDefault(); }
}
if (isShortcutMatch(event, shortcuts.leftDecrement)) {
scoreboardStore.leftScore = Math.max(0, scoreboardStore.leftScore - 1);
event.preventDefault();
return;
}
if (isShortcutMatch(event, shortcuts.rightIncrement)) {
scoreboardStore.rightScore += 1;
event.preventDefault();
return;
}
if (isShortcutMatch(event, shortcuts.rightDecrement)) {
scoreboardStore.rightScore = Math.max(0, scoreboardStore.rightScore - 1);
event.preventDefault();
}
}; };
onMounted(() => { onMounted(() => {
window.addEventListener('keydown', onShortcutPress); window.addEventListener('keydown', onShortcutPress);
window.addEventListener('online', onNetworkOnline);
window.addEventListener('offline', onNetworkOffline);
pingInterval = setInterval(checkOnline, 15_000);
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', onShortcutPress); window.removeEventListener('keydown', onShortcutPress);
window.removeEventListener('online', onNetworkOnline);
window.removeEventListener('offline', onNetworkOffline);
if (pingInterval) clearInterval(pingInterval);
}); });
</script> </script>
@@ -71,49 +91,117 @@ onUnmounted(() => {
show-if-above show-if-above
side="left" side="left"
bordered bordered
:width="220" :width="drawerWidth"
class="sidebar-drawer" class="sidebar-drawer"
> >
<div class="sidebar-header q-pa-md"> <!-- Header -->
<div class="row items-center no-wrap"> <div class="sidebar-header" :class="{ 'is-collapsed': isCollapsed }">
<img <img :src="logoUrl" alt="Logo" class="sidebar-logo">
:src="logoUrl"
alt="Logo" <Transition name="slide-fade">
class="sidebar-logo" <div v-if="!isCollapsed" class="sidebar-title">
> <span class="title-text">Scoreko-dev</span>
<div class="q-ml-sm"> <span v-if="appVersion" class="title-version">v{{ appVersion }}</span>
<div class="text-subtitle1 text-weight-bold">
Scoreko-dev
</div>
<div class="text-caption">
<span class="by-label">by</span> <a
class="by-link"
href="https://github.com/Pandipipas"
target="_blank"
rel="noopener"
>Pandipipas</a>
</div>
</div> </div>
</div> </Transition>
<!-- Chevron siempre visible, arriba a la derecha -->
<QBtn
flat
round
dense
size="sm"
:icon="isCollapsed ? 'chevron_right' : 'chevron_left'"
class="collapse-btn"
@click="toggleCollapse"
/>
</div> </div>
<QSeparator class="q-mb-sm" />
<QList> <QSeparator />
<!-- Sección MAIN -->
<div class="section-sep" :class="{ 'is-collapsed': isCollapsed }">
<span v-if="!isCollapsed" class="section-label">MAIN</span>
</div>
<QList padding>
<QItem <QItem
v-for="item in menuItems" v-for="item in mainItems"
:key="item.to" :key="item.to"
clickable clickable
:to="item.to" :to="item.to"
exact exact
active-class="sidebar-item-active" active-class="sidebar-item-active"
:class="{ 'nav-item-collapsed': isCollapsed }"
> >
<QItemSection avatar> <QItemSection avatar>
<QIcon :name="item.icon" /> <QIcon :name="item.icon" size="sm" />
</QItemSection> </QItemSection>
<QItemSection> <QItemSection v-if="!isCollapsed">
<QItemLabel>{{ item.label }}</QItemLabel> <QItemLabel>{{ item.label }}</QItemLabel>
</QItemSection> </QItemSection>
<QTooltip
v-if="isCollapsed"
anchor="center right"
self="center left"
:offset="[10, 0]"
>
{{ item.label }}
</QTooltip>
</QItem> </QItem>
</QList> </QList>
<!-- Sección CONFIG -->
<div class="section-sep" :class="{ 'is-collapsed': isCollapsed }">
<span v-if="!isCollapsed" class="section-label">CONFIG</span>
</div>
<QList padding>
<QItem
v-for="item in configItems"
:key="item.to"
clickable
:to="item.to"
exact
active-class="sidebar-item-active"
:class="{ 'nav-item-collapsed': isCollapsed }"
>
<QItemSection avatar>
<QIcon :name="item.icon" size="sm" />
</QItemSection>
<QItemSection v-if="!isCollapsed">
<QItemLabel>{{ item.label }}</QItemLabel>
</QItemSection>
<QTooltip
v-if="isCollapsed"
anchor="center right"
self="center left"
:offset="[10, 0]"
>
{{ item.label }}
</QTooltip>
</QItem>
</QList>
<!-- Footer: Online / Offline -->
<div class="sidebar-footer" :class="{ 'is-collapsed': isCollapsed }">
<div class="online-row">
<span class="online-dot" :class="isOnline ? 'dot-online' : 'dot-offline'" />
<Transition name="slide-fade">
<span v-if="!isCollapsed" class="online-label">
{{ isOnline ? 'Online' : 'Offline' }}
</span>
</Transition>
<QTooltip
v-if="isCollapsed"
anchor="center right"
self="center left"
:offset="[10, 0]"
>
{{ isOnline ? 'Online' : 'Offline' }}
</QTooltip>
</div>
</div>
</QDrawer> </QDrawer>
<QPageContainer> <QPageContainer>
@@ -123,28 +211,176 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
/* ── Drawer shell ─────────────────────────────────────────────────────────── */
.sidebar-drawer :deep(.q-drawer__content) {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* ── Header ───────────────────────────────────────────────────────────────── */
.sidebar-header { .sidebar-header {
min-height: 72px; display: flex;
align-items: center;
gap: 10px;
padding: 14px 12px 14px 14px;
min-height: 64px;
position: relative;
flex-shrink: 0;
transition: padding 0.25s ease;
}
.sidebar-header.is-collapsed {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 4px;
} }
.sidebar-logo { .sidebar-logo {
width: 40px; width: 36px;
height: 40px; height: 36px;
object-fit: contain; object-fit: contain;
flex-shrink: 0;
} }
.by-label { .sidebar-title {
font-size: 0.75rem; display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
}
.title-text {
font-size: 0.875rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title-version {
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 0.65rem;
letter-spacing: 0.05em;
opacity: 0.45;
margin-top: 1px;
} }
.by-link { /* ── Collapse button ──────────────────────────────────────────────────────── */
font-size: 0.75rem; .collapse-btn {
color: #f50a64; flex-shrink: 0;
text-decoration: none; opacity: 0.4;
transition: opacity 0.2s ease, transform 0.25s ease;
}
.collapse-btn:hover {
opacity: 1;
}
/* en modo expandido queda al extremo derecho */
.sidebar-header:not(.is-collapsed) .collapse-btn {
margin-left: auto;
} }
.by-link:hover { /* ── Section separators ───────────────────────────────────────────────────── */
text-decoration: underline; .section-sep {
display: flex;
align-items: center;
padding: 10px 14px 2px;
min-height: 28px;
transition: padding 0.2s ease, min-height 0.2s ease;
}
.section-sep.is-collapsed {
padding: 6px 12px 2px;
min-height: 0;
}
.section-sep.is-collapsed::after {
content: '';
display: block;
width: 100%;
height: 1px;
background: currentColor;
opacity: 0.12;
} }
</style> .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>
+178 -375
View File
@@ -35,7 +35,7 @@ const playersStore = usePlayersStore();
const $q = useQuasar(); const $q = useQuasar();
const rows = computed<PlayerRow[]>(() => playersStore.rows); const rows = computed<PlayerRow[]>(() => playersStore.rows);
// ─── Integraciones ───────────────────────────────────────────────────────────── // ─── Integraciones (solo se usa para importar torneos) ─────────────────────────
const startgg = useIntegration({ const startgg = useIntegration({
messagePrefix: 'startgg', messagePrefix: 'startgg',
@@ -57,7 +57,6 @@ const challonge = useIntegration({
playersStore, playersStore,
}); });
// Notifica errores de apertura del diálogo de importación (sustituye window.alert)
watch(() => startgg.importDialogError, (msg) => { watch(() => startgg.importDialogError, (msg) => {
if (msg) $q.notify({ type: 'negative', message: msg }); if (msg) $q.notify({ type: 'negative', message: msg });
}); });
@@ -83,12 +82,6 @@ const formatExpiresAt = (ts: number): string =>
year: 'numeric', year: 'numeric',
}); });
// ─── Label de conexión de Challonge ───────────────────────────────────────────
const challongeConnectionLabel = computed(() =>
challonge.hasValidatedToken ? t('playersConnected') : 'Token set',
);
// ─── Tabla de jugadores ──────────────────────────────────────────────────────── // ─── Tabla de jugadores ────────────────────────────────────────────────────────
const filter = ref(''); const filter = ref('');
@@ -152,7 +145,6 @@ const openCreateDialog = () => {
const openEditDialog = (row: PlayerRow) => { const openEditDialog = (row: PlayerRow) => {
editingId.value = row.id; editingId.value = row.id;
// CORRECCIÓN: evitar el patrón `void id` usando un alias _id
const { id: _id, ...playerData } = row; const { id: _id, ...playerData } = row;
Object.assign(form, playerData); Object.assign(form, playerData);
isDialogOpen.value = true; isDialogOpen.value = true;
@@ -169,34 +161,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 ────────────────────────────────────────────────── // ─── Exportar / importar JSON ──────────────────────────────────────────────────
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
@@ -232,6 +196,8 @@ const handleImport = async (event: Event) => {
<template> <template>
<QPage class="q-pa-lg players-page"> <QPage class="q-pa-lg players-page">
<!-- Cabecera -->
<div class="row items-center q-mb-md"> <div class="row items-center q-mb-md">
<div class="text-h5 text-weight-medium"> <div class="text-h5 text-weight-medium">
{{ t('menuPlayers') }} {{ t('menuPlayers') }}
@@ -248,7 +214,9 @@ const handleImport = async (event: Event) => {
</div> </div>
<div class="players-content row q-col-gutter-md"> <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"> <div class="row items-center q-gutter-sm q-mb-md">
<QInput <QInput
v-model="filter" v-model="filter"
@@ -262,19 +230,14 @@ const handleImport = async (event: Event) => {
</template> </template>
</QInput> </QInput>
<span class="text-caption text-grey-6">{{ rows.length }} players</span> <span class="text-caption text-grey-6">{{ rows.length }} players</span>
<QSpace />
<QBtn <QBtn
color="secondary" color="secondary" outline icon="file_upload" no-caps
outline
icon="file_upload"
no-caps
:label="t('playersImport')" :label="t('playersImport')"
@click="triggerImport" @click="triggerImport"
/> />
<QBtn <QBtn
color="secondary" color="secondary" outline icon="file_download" no-caps
outline
icon="file_download"
no-caps
:label="t('playersExport')" :label="t('playersExport')"
@click="exportPlayers" @click="exportPlayers"
/> />
@@ -286,9 +249,7 @@ const handleImport = async (event: Event) => {
@change="handleImport" @change="handleImport"
> >
</div> </div>
</div>
<div class="col-12 col-lg-8 players-main-column">
<QTable <QTable
flat flat
bordered bordered
@@ -305,261 +266,198 @@ const handleImport = async (event: Event) => {
<QChip <QChip
v-if="playerSource(row.id) === 'startgg'" v-if="playerSource(row.id) === 'startgg'"
dense dense
outline
color="blue-4"
class="q-my-none q-mr-none q-ml-xs" 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 <svg style="width: 12px; height: 12px; flex-shrink: 0;" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<QTooltip v-if="playerExpiresAt(row.id)"> <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" />
Temporary · expires {{ formatExpiresAt(playerExpiresAt(row.id)!) }} <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> </QTooltip>
</QChip> </QChip>
<QChip <QChip
v-else-if="playerSource(row.id) === 'challonge'" v-else-if="playerSource(row.id) === 'challonge'"
dense dense
outline
color="orange-4"
class="q-my-none q-mr-none q-ml-xs" 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 <img src="https://challonge.com/favicon.ico" alt="Challonge" style="width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0;">
<QTooltip v-if="playerExpiresAt(row.id)"> <QTooltip>
Temporary · expires {{ formatExpiresAt(playerExpiresAt(row.id)!) }} Challonge<template v-if="playerExpiresAt(row.id)"> · Temporary · expires {{ formatExpiresAt(playerExpiresAt(row.id)!) }}</template>
</QTooltip> </QTooltip>
</QChip> </QChip>
</div> </div>
<div <div v-if="row.name" class="text-caption text-grey-6">
v-if="row.name"
class="text-caption text-grey-6"
>
{{ row.name }} {{ row.name }}
</div> </div>
</QTd> </QTd>
</template> </template>
<template #body-cell-actions="{ row }"> <template #body-cell-actions="{ row }">
<QTd align="right"> <QTd align="right">
<QBtn <QBtn size="sm" flat icon="edit" @click="openEditDialog(row)" />
size="sm" <QBtn size="sm" flat color="negative" icon="delete" @click="deletePlayer(row)" />
flat
icon="edit"
@click="openEditDialog(row)"
/>
<QBtn
size="sm"
flat
color="negative"
icon="delete"
@click="deletePlayer(row)"
/>
</QTd> </QTd>
</template> </template>
</QTable> </QTable>
</div> </div>
<!-- Panel de integraciones --> <!-- Columna lateral: importar desde torneo -->
<div class="col-12 col-lg-4 players-startgg-column"> <div class="col-12 col-lg-4 players-import-column">
<div class="players-integrations-stack"> <div class="players-integrations-stack">
<!-- start.gg --> <!-- start.gg -->
<QCard flat bordered class="q-pa-md"> <QCard flat bordered class="q-pa-md">
<div class="text-h6 q-mb-sm startgg-heading"> <div class="row items-center q-mb-xs q-gutter-x-sm">
<svg class="startgg-heading__icon" viewBox="0 0 24 24" aria-hidden="true"> <svg style="width: 18px; height: 18px; flex-shrink: 0;" viewBox="0 0 40 40" fill="none" 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" /> <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> </svg>
<span>start.gg</span> <span class="text-subtitle2">start.gg</span>
</div> <QSpace />
<div class="text-caption text-grey-6 q-mb-md"> <QChip
{{ t('playersStartggHelp') }} v-if="startgg.hasTokenConfigured"
</div> dense size="sm"
<div class="row q-col-gutter-sm items-center"> :color="startgg.hasValidatedToken ? 'positive' : 'warning'"
<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
text-color="white" text-color="white"
icon="sync" >
class="startgg-refresh-btn" {{ t('playersConnected') }}
:loading="startgg.loadingTournaments" </QChip>
@click="startgg.loadRecentTournaments" <QChip v-else dense size="sm" color="grey-7" text-color="white" icon="link_off">
/> {{ t('settingsNotConnected') || 'Not connected' }}
<div class="col"> </QChip>
<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>
</div> </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> </QCard>
<!-- Challonge --> <!-- Challonge -->
<QCard flat bordered class="q-pa-md"> <QCard flat bordered class="q-pa-md">
<div class="text-h6 q-mb-sm startgg-heading"> <div class="row items-center q-mb-xs q-gutter-x-sm">
<img <img src="https://challonge.com/favicon.ico" alt="Challonge" style="width: 18px; height: 18px; border-radius: 4px; flex-shrink: 0;">
class="challonge-heading__icon" <span class="text-subtitle2">Challonge</span>
src="https://challonge.com/favicon.ico" <QSpace />
alt="Challonge" <QChip
> v-if="challonge.hasTokenConfigured"
<span>Challonge</span> dense size="sm"
</div> :color="challonge.hasValidatedToken ? 'positive' : 'warning'"
<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
text-color="white" text-color="white"
icon="sync" >
class="startgg-refresh-btn" {{ challonge.hasValidatedToken ? t('playersConnected') : 'Token set' }}
:loading="challonge.loadingTournaments" </QChip>
@click="challonge.loadRecentTournaments" <QChip v-else dense size="sm" color="grey-7" text-color="white" icon="link_off">
/> {{ t('settingsNotConnected') || 'Not connected' }}
<div class="col"> </QChip>
<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>
</div> </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> </QCard>
</div> </div>
@@ -570,9 +468,7 @@ const handleImport = async (event: Event) => {
<QDialog v-model="startgg.importDialogOpen"> <QDialog v-model="startgg.importDialogOpen">
<QCard class="players-dialog"> <QCard class="players-dialog">
<QCardSection> <QCardSection>
<div class="text-h6"> <div class="text-h6">Import from {{ startgg.importingTournament?.name || 'start.gg' }}</div>
Import from {{ startgg.importingTournament?.name || 'start.gg' }}
</div>
</QCardSection> </QCardSection>
<QSeparator /> <QSeparator />
<QCardSection> <QCardSection>
@@ -617,9 +513,7 @@ const handleImport = async (event: Event) => {
<QDialog v-model="challonge.importDialogOpen"> <QDialog v-model="challonge.importDialogOpen">
<QCard class="players-dialog"> <QCard class="players-dialog">
<QCardSection> <QCardSection>
<div class="text-h6"> <div class="text-h6">Import from {{ challonge.importingTournament?.name || 'Challonge' }}</div>
Import from {{ challonge.importingTournament?.name || 'Challonge' }}
</div>
</QCardSection> </QCardSection>
<QSeparator /> <QSeparator />
<QCardSection> <QCardSection>
@@ -660,65 +554,6 @@ const handleImport = async (event: Event) => {
</QCard> </QCard>
</QDialog> </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 --> <!-- Diálogo crear / editar jugador -->
<QDialog v-model="isDialogOpen"> <QDialog v-model="isDialogOpen">
<QCard class="players-dialog"> <QCard class="players-dialog">
@@ -750,18 +585,11 @@ const handleImport = async (event: Event) => {
v-model="form.country" v-model="form.country"
v-model:input-value="countryInput" v-model:input-value="countryInput"
:options="filteredCountryOptions" :options="filteredCountryOptions"
option-value="value" option-value="value" option-label="label"
option-label="label" emit-value map-options use-input input-debounce="0"
emit-value hide-selected fill-input clearable
map-options
use-input
input-debounce="0"
hide-selected
fill-input
clearable
:label="t('playersLabelCountry')" :label="t('playersLabelCountry')"
dense dense class="players-underlined-field"
class="players-underlined-field"
@filter="filterCountries" @filter="filterCountries"
/> />
</div> </div>
@@ -781,6 +609,7 @@ const handleImport = async (event: Event) => {
</QCardActions> </QCardActions>
</QCard> </QCard>
</QDialog> </QDialog>
</QPage> </QPage>
</template> </template>
@@ -804,8 +633,8 @@ const handleImport = async (event: Event) => {
flex: 1 1 auto; flex: 1 1 auto;
} }
.players-startgg-column { .players-import-column {
min-width: 320px; min-width: 300px;
} }
.players-integrations-stack { .players-integrations-stack {
@@ -819,36 +648,14 @@ const handleImport = async (event: Event) => {
width: min(720px, 90vw); width: min(720px, 90vw);
} }
.startgg-heading { .players-tournament-row {
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 {
gap: 4px; gap: 4px;
} }
.startgg-refresh-btn:hover { .players-refresh-btn:hover {
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
} }
.startgg-connected-btn {
font-weight: 600;
}
.players-underlined-field :deep(.q-field__control) { .players-underlined-field :deep(.q-field__control) {
min-height: 28px; min-height: 28px;
padding: 0; padding: 0;
@@ -862,10 +669,6 @@ const handleImport = async (event: Event) => {
border-bottom: 1px solid rgba(255, 255, 255, 0.34); border-bottom: 1px solid rgba(255, 255, 255, 0.34);
} }
.manual-token-steps {
line-height: 1.5;
}
.visually-hidden { .visually-hidden {
position: absolute; position: absolute;
width: 1px; width: 1px;
+397 -151
View File
@@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useHead } from '@unhead/vue'; 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 type { Locale } from '../i18n';
import { locale, setLocale, t } from '../i18n'; import { locale, setLocale, t } from '../i18n';
import { usePlayersStore } from '../stores/players';
import { import {
eventToShortcut, eventToShortcut,
type ShortcutAction, type ShortcutAction,
@@ -13,6 +16,8 @@ defineOptions({ name: 'SettingsView' });
useHead(() => ({ title: t('settingsTitle') })); useHead(() => ({ title: t('settingsTitle') }));
// ─── Idioma ────────────────────────────────────────────────────────────────────
const languageOptions = computed(() => [ const languageOptions = computed(() => [
{ label: t('languageSpanish'), value: 'es' as const }, { label: t('languageSpanish'), value: 'es' as const },
{ label: t('languageEnglish'), value: 'en' as const }, { label: t('languageEnglish'), value: 'en' as const },
@@ -20,15 +25,13 @@ const languageOptions = computed(() => [
const selectedLanguage = computed<Locale>({ const selectedLanguage = computed<Locale>({
get: () => locale.value, get: () => locale.value,
set: (value) => { set: (value) => { setLocale(value); },
setLocale(value);
},
}); });
// ─── Atajos de teclado ─────────────────────────────────────────────────────────
const shortcutSettingsStore = useShortcutSettingsStore(); const shortcutSettingsStore = useShortcutSettingsStore();
const recordingAction = ref<ShortcutAction | null>(null); const recordingAction = ref<ShortcutAction | null>(null);
// Ref para detectar clicks fuera del contenedor de atajos
const shortcutsContainerRef = ref<HTMLElement | null>(null); const shortcutsContainerRef = ref<HTMLElement | null>(null);
const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: string }[]>(() => [ const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: string }[]>(() => [
@@ -38,7 +41,6 @@ const shortcutFields = computed<{ action: ShortcutAction; label: string; hint: s
{ action: 'rightDecrement', label: t('settingsShortcutRightDecrementLabel'), hint: t('settingsShortcutRightDecrementHint') }, { action: 'rightDecrement', label: t('settingsShortcutRightDecrementLabel'), hint: t('settingsShortcutRightDecrementHint') },
]); ]);
// Detecta atajos duplicados entre acciones
const conflictingActions = computed(() => { const conflictingActions = computed(() => {
const seen = new Map<string, ShortcutAction>(); const seen = new Map<string, ShortcutAction>();
const conflicts = new Set<ShortcutAction>(); const conflicts = new Set<ShortcutAction>();
@@ -62,43 +64,24 @@ const stopRecording = () => {
const onRecordKeydown = (event: KeyboardEvent) => { const onRecordKeydown = (event: KeyboardEvent) => {
if (!recordingAction.value) return; if (!recordingAction.value) return;
if (event.key === 'Escape') { event.preventDefault(); stopRecording(); return; }
// Escape cancela la grabación sin asignar ningún atajo
if (event.key === 'Escape') {
event.preventDefault();
stopRecording();
return;
}
const shortcut = eventToShortcut(event); const shortcut = eventToShortcut(event);
if (!shortcut) return; if (!shortcut) return;
event.preventDefault(); event.preventDefault();
shortcutSettingsStore.setShortcut(recordingAction.value, shortcut); shortcutSettingsStore.setShortcut(recordingAction.value, shortcut);
stopRecording(); stopRecording();
}; };
// Click fuera del área de atajos también cancela la grabación
const onDocumentMousedown = (event: MouseEvent) => { const onDocumentMousedown = (event: MouseEvent) => {
if ( if (recordingAction.value && shortcutsContainerRef.value && !shortcutsContainerRef.value.contains(event.target as Node)) {
recordingAction.value &&
shortcutsContainerRef.value &&
!shortcutsContainerRef.value.contains(event.target as Node)
) {
stopRecording(); stopRecording();
} }
}; };
const startRecording = (action: ShortcutAction) => { const startRecording = (action: ShortcutAction) => {
if (recordingAction.value === action) { if (recordingAction.value === action) { stopRecording(); return; }
stopRecording();
return;
}
recordingAction.value = action; recordingAction.value = action;
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') document.body.dataset.shortcutRecording = 'true';
document.body.dataset.shortcutRecording = 'true';
}
}; };
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -113,6 +96,79 @@ onBeforeUnmount(() => {
} }
stopRecording(); 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> </script>
<template> <template>
@@ -126,134 +182,324 @@ onBeforeUnmount(() => {
</div> </div>
</div> </div>
<QCard <div class="column q-gutter-lg settings-layout">
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="text-caption text-grey-6 q-mt-sm"> <!-- Idioma -->
{{ t('settingsLanguageHint') }} <QCard flat bordered class="settings-card">
</div> <QCardSection class="q-pa-lg">
</QCardSection> <div class="text-overline text-grey-6 q-mb-md">{{ t('settingsLanguageLabel') }}</div>
<QSelect
<QSeparator /> v-model="selectedLanguage"
emit-value
<!-- Shortcuts --> map-options
<QCardSection class="q-pa-lg"> :options="languageOptions"
<div class="row items-center justify-between q-mb-xs"> :label="t('settingsLanguageLabel')"
<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
outlined outlined
dense dense
bottom-slots style="max-width: 280px"
:label="field.label" />
> <div class="text-caption text-grey-6 q-mt-sm">
<template #append> {{ t('settingsLanguageHint') }}
<!-- Botón grabar / detener --> </div>
<QBtn </QCardSection>
flat </QCard>
round
dense
:icon="recordingAction === field.action ? 'stop_circle' : 'keyboard'"
:color="recordingAction === field.action ? 'negative' : 'primary'"
:aria-label="
recordingAction === field.action
? t('settingsShortcutStopRecording')
: t('settingsShortcutStartRecording')
"
@click="startRecording(field.action)"
/>
<!-- Botón reset individual por atajo --> <!-- Integraciones -->
<QBtn <QCard flat bordered class="settings-card">
flat <QCardSection class="q-pa-lg">
round <div class="text-overline text-grey-6 q-mb-xs">{{ t('settingsIntegrationsTitle') || 'Integrations' }}</div>
dense <div class="text-caption text-grey-6 q-mb-lg">
icon="restart_alt" {{ t('settingsIntegrationsDescription') || 'Connect your tournament platform accounts to import players directly from brackets.' }}
color="grey-5" </div>
:aria-label="t('settingsShortcutResetSingle')"
@click="shortcutSettingsStore.resetShortcut(field.action)" <div class="column q-gutter-md">
>
<QTooltip>{{ t('settingsShortcutResetSingle') }}</QTooltip> <!-- start.gg -->
</QBtn> <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> </template>
</QInput> {{ t('settingsShortcutConflictWarning') }}
</div> </QBanner>
</QCardSection>
</QCard> <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> </QPage>
</template> </template>
<style scoped> <style scoped>
.settings-card { .settings-layout {
max-width: 600px; 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>
+113 -42
View File
@@ -20,6 +20,14 @@ const CHALLONGE_OAUTH_DEFAULT_PORT = 34921;
const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000; const CHALLONGE_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 20; 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 ───────────────────────────────────────────────────────────────────── // ─── Tipos ─────────────────────────────────────────────────────────────────────
interface OAuthTokenResponse { interface OAuthTokenResponse {
@@ -46,34 +54,54 @@ interface ImportedPlayer {
twitter: string; 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 => { type OAuthMode =
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>; | { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
const clientId = String(bundleConfig.challongeClientId ?? '').trim(); | { 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 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 = const callbackPort =
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : CHALLONGE_OAUTH_DEFAULT_PORT; 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, code: string,
redirectUri: string, redirectUri: string,
config: OAuthConfig, clientId: string,
clientSecret: string,
): Promise<string> => { ): Promise<string> => {
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
client_id: config.clientId, client_id: clientId,
client_secret: config.clientSecret, client_secret: clientSecret,
redirect_uri: redirectUri, redirect_uri: redirectUri,
}); });
const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, { const response = await fetch(CHALLONGE_OAUTH_TOKEN_ENDPOINT, {
@@ -112,6 +140,50 @@ const exchangeOAuthCodeForToken = async (
return token; return token;
}; };
/** Modo proxy: el Worker hace el exchange; el clientSecret nunca sale del Worker */
const exchangeCodeViaProxy = async (
code: string,
redirectUri: string,
proxyBaseUrl: string,
): Promise<string> => {
const response = await fetch(`${proxyBaseUrl}/oauth/challonge/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, redirectUri }),
});
const rawBody = await response.text();
let payload: { access_token?: string; error?: string };
try {
payload = JSON.parse(rawBody) as typeof payload;
} catch {
payload = { error: rawBody };
}
if (!response.ok) {
throw new Error(payload.error ?? `Proxy responded with ${response.status}`);
}
const token = String(payload.access_token ?? '').trim();
if (!token) throw new Error(payload.error ?? 'Proxy did not return a token');
return token;
};
/**
* Callback que recibe oauth-server.ts cuando llega el código de autorización.
* Delega al modo correcto; _config no se usa porque el modo ya está determinado.
*/
const exchangeOAuthCodeForToken = async (
code: string,
redirectUri: string,
_config: OAuthConfig,
): Promise<string> => {
const mode = getOAuthMode();
if (mode.type === 'dev') {
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
}
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
};
// ─── Servidor OAuth ──────────────────────────────────────────────────────────── // ─── Servidor OAuth ────────────────────────────────────────────────────────────
const oauthServer = createOAuthServer({ const oauthServer = createOAuthServer({
@@ -137,17 +209,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 requestChallonge = async (path: string, token: string): Promise<unknown> => {
const requestUrl = `${CHALLONGE_API_BASE}${path}`; const requestUrl = `${CHALLONGE_API_BASE}${path}`;
@@ -297,19 +358,13 @@ const parseImportedPlayers = (payload: unknown): ImportedPlayer[] => {
if (!id || !rawDisplayName) return; if (!id || !rawDisplayName) return;
// Detectar patrón "TEAM | Gamertag" o "TEAM |Gamertag" (muy común en fighting games).
// Si se detecta, extraer el equipo del propio nombre y limpiar el gamertag.
const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/; const PIPE_PATTERN = /^(.+?)\s*\|\s*(.+)$/;
const pipeMatch = PIPE_PATTERN.exec(rawDisplayName); const pipeMatch = PIPE_PATTERN.exec(rawDisplayName);
const teamFromName = pipeMatch ? pipeMatch[1].trim() : ''; const teamFromName = pipeMatch ? pipeMatch[1].trim() : '';
const gamertag = pipeMatch ? pipeMatch[2].trim() : rawDisplayName; 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; 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, { map.set(id, {
id, id,
gamertag, gamertag,
@@ -361,24 +416,40 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
// ─── Listeners de NodeCG ─────────────────────────────────────────────────────── // ─── Listeners de NodeCG ───────────────────────────────────────────────────────
nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => { nodecg.listenFor('challonge:createOAuthSession', async (_payload: unknown, ack) => {
const config = getOAuthConfig(); const mode = getOAuthMode();
if (!config) { let serverConfig: OAuthConfig;
sendAck(
ack, if (mode.type === 'dev') {
'OAuth is not configured in this installation (missing challongeClientId/challongeClientSecret). Use the Client ID and Client Secret from a Challonge OAuth app.', serverConfig = {
); clientId: mode.clientId,
return; callbackPort: mode.callbackPort,
};
} else {
// Modo proxy: el clientId viene del Worker (es público, no secreto)
try {
const res = await fetch(`${mode.proxyBaseUrl}/oauth/challonge/client-id`);
if (!res.ok) throw new Error(`Proxy responded with ${res.status}`);
const data = await res.json() as { clientId?: string };
const clientId = String(data.clientId ?? '').trim();
if (!clientId) throw new Error('Proxy did not return a clientId');
serverConfig = { clientId, callbackPort: mode.callbackPort };
} catch (err) {
sendAck(
ack,
err instanceof Error ? err.message : 'Could not fetch OAuth config from proxy',
);
return;
}
} }
try { try {
await oauthServer.ensureServer(config); await oauthServer.ensureServer(serverConfig);
} catch (err) { } 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; return;
} }
const session = oauthServer.createSession(config); sendAck(ack, null, oauthServer.createSession(serverConfig));
sendAck(ack, null, session);
}); });
nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => { nodecg.listenFor('challonge:getOAuthSessionStatus', (payload: unknown, ack) => {
+1
View File
@@ -11,4 +11,5 @@ export default async (nodecg: NodeCGServerAPI) => {
await import('./example.js'); await import('./example.js');
await import('./startgg.js'); await import('./startgg.js');
await import('./challonge.js'); await import('./challonge.js');
await import('./pack-manager.js');
}; };
+441
View File
@@ -0,0 +1,441 @@
// 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 './util/nodecg.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 ─────────────────────────────────────────────────────────────
interface PackCharacter {
name: string;
slug: string;
dlc?: boolean;
sizeBytes: number;
}
interface PackManifest {
id: string;
name: string;
version: string;
palette: { start: string; end: string };
defaultPair?: { left: string; right: string };
characters: PackCharacter[];
}
interface PackRegistry {
schemaVersion: number;
updatedAt: string;
packs: Array<{
id: string;
name: string;
version: string;
totalSizeBytes: number;
logoPath: string;
characterCount: number;
palette: { start: string; end: string };
bundled: boolean;
}>;
}
interface PackDownloadState {
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
progress: number;
error?: string;
}
// 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.
type HandledAcknowledgement = { handled: true };
type UnhandledAcknowledgement = ((error?: Error | null, ...args: unknown[]) => void) & { handled: false };
type Acknowledgement = HandledAcknowledgement | UnhandledAcknowledgement;
const reply = (ack: Acknowledgement | undefined, err: Error | null, result?: unknown): void => {
if (ack && !ack.handled) ack(err ?? undefined, result);
};
// ── 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 = nodecg.Replicant<string[]>('installedPacks', {
defaultValue: [],
persistent: true,
});
const packRegistryRep = nodecg.Replicant<PackRegistry | null>('packRegistry', {
defaultValue: null,
persistent: true,
});
const downloadStatesRep = nodecg.Replicant<Record<string, PackDownloadState>>('downloadStates', {
defaultValue: {},
persistent: false,
});
/** Packs instalados para los que hay una versión más nueva en el registro. */
const availableUpdatesRep = nodecg.Replicant<Record<string, { installedVersion: string; latestVersion: string }>>('availableUpdates', {
defaultValue: {},
persistent: false,
});
// ── 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 ────────────────────────────────────────────────
nodecg.listenFor('fetchPackRegistry', 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 ─────────────────────────────────────────────────────
nodecg.listenFor('downloadPack', 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 ────────────────────────────────────────────────────
nodecg.listenFor('uninstallPack', (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.
nodecg.listenFor('updatePack', 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 ────────────────────────────────────────────────
nodecg.listenFor('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}`));
}
});
+116 -27
View File
@@ -1,6 +1,6 @@
import { getData, type CountryRecord } from 'country-list'; import { getData, type CountryRecord } from 'country-list';
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
import { nodecg } from './util/nodecg.js'; import { nodecg } from './util/nodecg.js';
import { createOAuthServer, type OAuthConfig } from './util/oauth-server.js';
// ─── Constantes ──────────────────────────────────────────────────────────────── // ─── Constantes ────────────────────────────────────────────────────────────────
@@ -18,6 +18,14 @@ const STARTGG_OAUTH_SESSION_TTL_MS = 10 * 60 * 1000;
const RECENT_TOURNAMENTS_LIMIT = 12; const RECENT_TOURNAMENTS_LIMIT = 12;
const PARTICIPANTS_PAGE_SIZE = 120; const PARTICIPANTS_PAGE_SIZE = 120;
// ─── URL del proxy OAuth ───────────────────────────────────────────────────────
// Rellena esta constante con la URL de tu Cloudflare Worker tras el deploy.
// Formato: 'https://scoreko-oauth-proxy.TU-SUBDOMINIO.workers.dev'
//
// También puedes sobreescribirla en cfg/scoreko.json con "oauthProxyUrl"
// (útil para apuntar a un entorno de staging sin recompilar).
const OAUTH_PROXY_BASE_URL = 'https://scoreko-oauth-proxy.panver.workers.dev';
// ─── Tipos ───────────────────────────────────────────────────────────────────── // ─── Tipos ─────────────────────────────────────────────────────────────────────
interface StartGGGraphQLResponse<T> { interface StartGGGraphQLResponse<T> {
@@ -49,22 +57,41 @@ interface OAuthTokenResponse {
message?: string; 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 => { type OAuthMode =
const bundleConfig = nodecg.bundleConfig as unknown as Record<string, unknown>; | { type: 'dev'; clientId: string; clientSecret: string; callbackPort: number }
const clientId = String(bundleConfig.startggClientId ?? '').trim(); | { 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 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 = const callbackPort =
Number.isFinite(rawPort) && rawPort > 0 ? rawPort : STARTGG_OAUTH_DEFAULT_PORT; 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 parseOAuthTokenPayload = async (response: Response): Promise<OAuthTokenResponse> => {
const rawBody = await response.text(); const rawBody = await response.text();
@@ -75,17 +102,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, code: string,
redirectUri: string, redirectUri: string,
config: OAuthConfig, clientId: string,
clientSecret: string,
): Promise<string> => { ): Promise<string> => {
const params = new URLSearchParams({ const params = new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
client_id: config.clientId, client_id: clientId,
client_secret: config.clientSecret, client_secret: clientSecret,
redirect_uri: redirectUri, redirect_uri: redirectUri,
}); });
let lastError = 'Unknown OAuth token exchange error'; let lastError = 'Unknown OAuth token exchange error';
@@ -116,13 +145,56 @@ const exchangeOAuthCodeForToken = async (
payload.message ?? payload.message ??
`OAuth token request failed (${response.status})`; `OAuth token request failed (${response.status})`;
// Solo 404 justifica probar el siguiente endpoint
if (response.status !== 404) break; if (response.status !== 404) break;
} }
throw new Error(lastError); 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> => {
const mode = getOAuthMode();
if (mode.type === 'dev') {
return exchangeCodeDirectly(code, redirectUri, mode.clientId, mode.clientSecret);
}
return exchangeCodeViaProxy(code, redirectUri, mode.proxyBaseUrl);
};
// ─── Servidor OAuth ──────────────────────────────────────────────────────────── // ─── Servidor OAuth ────────────────────────────────────────────────────────────
const oauthServer = createOAuthServer({ const oauthServer = createOAuthServer({
@@ -203,24 +275,41 @@ const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
// ─── Listeners de NodeCG ─────────────────────────────────────────────────────── // ─── Listeners de NodeCG ───────────────────────────────────────────────────────
nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => { nodecg.listenFor('startgg:createOAuthSession', async (_payload: unknown, ack) => {
const config = getOAuthConfig(); const mode = getOAuthMode();
if (!config) { let serverConfig: OAuthConfig;
sendAck(
ack, if (mode.type === 'dev') {
'OAuth is not configured in this installation (missing startggClientId/startggClientSecret). Use the Client ID and Client Secret from a start.gg OAuth app.', serverConfig = {
); clientId: mode.clientId,
return; 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 { try {
await oauthServer.ensureServer(config); await oauthServer.ensureServer(serverConfig);
} catch (err) { } 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; return;
} }
const session = oauthServer.createSession(config); sendAck(ack, null, oauthServer.createSession(serverConfig));
sendAck(ack, null, session);
}); });
nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => { nodecg.listenFor('startgg:getOAuthSessionStatus', (payload: unknown, ack) => {
+3 -1
View File
@@ -5,7 +5,9 @@ import { randomUUID } from 'node:crypto';
export interface OAuthConfig { export interface OAuthConfig {
clientId: string; 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; callbackPort: number;
} }
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 16 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

After

Width:  |  Height:  |  Size: 898 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 MiB

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 MiB

After

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 MiB

After

Width:  |  Height:  |  Size: 967 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 775 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 MiB

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 MiB

After

Width:  |  Height:  |  Size: 965 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 772 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 MiB

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 552 KiB

+71 -360
View File
@@ -1,339 +1,56 @@
// src/shared/fighting-characters.ts
// ─────────────────────────────────────────────────────────────────────────────
// Todo el contenido de personajes viene de packs descargados desde Gitea.
// No hay datos bundled — el proyecto arranca vacío y se rellena en runtime.
// ─────────────────────────────────────────────────────────────────────────────
import { ref } from 'vue';
import type { PackManifest } from './pack-types';
export interface FightingCharacterOption { export interface FightingCharacterOption {
label: string; label: string;
value: string; value: string;
image: string; image: string;
dlc?: boolean;
} }
type GamePalette = readonly [startColor: string, endColor: string]; // ── Runtime registry ──────────────────────────────────────────────────────────
const DEFAULT_PLACEHOLDER_PALETTE: GamePalette = ['#334155', '#0f172a']; const installedPackCharacters: Record<string, FightingCharacterOption[]> = {};
const MAX_INITIALS = 2; const installedPackDefaults: Record<string, { leftCharacter: string; rightCharacter: string }> = {};
const characterNamesByGame: Record<string, string[]> = { /**
'2XKO': [ * Incrementado cada vez que se registra o elimina un pack.
'Ahri', * Los composables se suscriben a este ref para que Vue invalide los computed
'Akali', * que dependen de installedPackCharacters (objeto plano, no reactivo).
'Braum', */
'Caitlyn', export const installedPacksRevision = ref(0);
'Darius',
'Ekko',
'Illaoi',
'Jinx',
'Senna',
'Teemo',
'Vi',
'Warwick',
'Yasuo',
],
'FATAL FURY: City of the Wolves': [
'Andy Bogard',
'B. Jenet',
'Billy Kane',
'Blue Mary',
'Chun-Li',
'Cristiano Ronaldo',
'Gato',
'Hokutomaru',
'Hotaru Futaba',
'Joe Higashi',
'Kain R. Heinlein',
'Ken Masters',
'Kenshiro',
'Kevin Rian',
'Kim Dong Hwan',
'Kim Jae Hoon',
'Mai Shiranui',
'Marco Rodrigues',
'Mr. Big',
'Mr. Karate',
'Nightmare Geese',
'Preecha',
'Rock Howard',
'Salvatore Ganacci',
'Terry Bogard',
'Tizoc',
'Vox Reaper',
'Wolfgang Krauser',
],
'Guilty Gear -Strive-': [
'A.B.A',
'Anji Mito',
'Asuka R. Kreutz',
'Axl Low',
'Baiken',
'Bedman?',
'Bridget',
'Chipp Zanuff',
'Dizzy',
'Elphelt Valentine',
'Faust',
'Giovanna',
'Goldlewis Dickinson',
'Happy Chaos',
'I-No',
'Jack-O',
'Johnny',
'Ky Kiske',
'Leo Whitefang',
'Lucy',
'May',
'Millia Rage',
'Nagoriyuki',
'Potemkin',
'Ramlethal Valentine',
'Sin Kiske',
'Slayer',
'Sol Badguy',
'Testament',
'Unika',
'Venom',
'Zato-1',
],
'Invincible VS': [
'Allen The Alien',
'Anissa',
'Atom Eve',
'Battle Beast',
'Bulletproof',
'Cecil',
'Conquest',
'Dupli-Kate',
'Ella Mental',
'Immortal',
'Invincible',
'Lucan',
'Monster Girl',
'Omni-Man',
'Power Plex',
'Rex Splode',
'Robot',
'Thula',
'Titan',
'Universa',
],
'Mortal Kombat 1': [
'Ashrah',
'Baraka',
'Conan the Barbarian',
'Cyrax',
'Ermac',
'Geras',
'Ghostface',
'Havik',
'Homelander',
'Johnny Cage',
'Kenshi',
'Kitana',
'Kung Lao',
'Li Mei',
'Liu Kang',
'Mileena',
'Nitara',
'Noob Saibot',
'Omni-Man',
'Peacemaker',
'Quan Chi',
'Raiden',
'Rain',
'Reiko',
'Reptile',
'Scorpion',
'Sektor',
'Shang Tsung',
'Sindel',
'Smoke',
'Sub-Zero',
'Takeda',
'Tanya',
'T-1000',
],
'Street Fighter 6': [
'A.K.I.',
'Akuma',
'Alex',
'Bison',
'Blanka',
'Cammy',
'Chun-Li',
'Dee Jay',
'Dhalsim',
'E. Honda',
'Ed',
'Elena',
'Guile',
'Jamie',
'JP',
'Juri',
'Ken',
'Kimberly',
'Lily',
'Luke',
'Mai',
'Manon',
'Marisa',
'Rashid',
'Ryu',
'Sagat',
'Terry',
'Viper',
'Zangief',
],
'TEKKEN 8': [
'Alisa',
'Anna',
'Armor King',
'Asuka',
'Azucena',
'Bob',
'Bryan',
'Claudio',
'Clive',
'Devil Jin',
'Dragunov',
'Eddy',
'Fahkumram',
'Feng',
'Heihachi',
'Hwoarang',
'Jack-8',
'Jin',
'Jun',
'Kazuya',
'King',
'Kuma',
'Kunimitsu',
'Lars',
'Law',
'Lee',
'Leo',
'Leroy',
'Lidia',
'Lili',
'Miary Zo',
'Nina',
'Panda',
'Paul',
'Raven',
'Reina',
'Roger Jr',
'Shaheen',
'Steve',
'Victor',
'Xiaoyu',
'Yoshimitsu',
'Zafina',
],
'THE KING OF FIGHTERS XV': [
'Angel',
'Antonov',
'Ash Crimson',
'Athena Asamiya',
'Benimaru Nikaido',
'Billy Kane',
'Blue Mary',
'Chizuru Kagura',
'Chris',
'Clark Still',
'Dolores',
'Duo Lon',
'Elisabeth Blanctorche',
'Gato',
'Geese Howard',
'Goenitz',
'Heidern',
'Hinako Shijo',
'Iori Yagami',
'Isla',
'Joe Higashi',
"K'",
'Kim Kaphwan',
'King',
'King of Dinosaurs',
'Krohnen McDougall',
'Kula Diamond',
'Kukri',
'Kyo Kusanagi',
'Leona Heidern',
'Luong',
'Mai Shiranui',
'Maxima',
'Meitenkun',
'Najd',
'Orochi Chris',
'Orochi Shermie',
'Orochi Yashiro',
'Ralf Jones',
'Ramón',
'Robert Garcia',
'Rock Howard',
'Ryo Sakazaki',
'Ryuji Yamazaki',
'Shermie',
'Shingo Yabuki',
'Sylvie Paula Paula',
'Terry Bogard',
'Vanessa',
'Whip',
'Yashiro Nanakase',
'Yuri Sakazaki',
],
};
const defaultCharacterPairByGame: Record<string, { leftCharacter: string; rightCharacter: string }> = { /**
'Guilty Gear -Strive-': { * Vacío — ya no hay juegos bundled.
leftCharacter: 'sol-badguy', * Mantenido por compatibilidad con usePackRegistry.
rightCharacter: 'ky-kiske', */
}, export const BUNDLED_GAME_NAMES = new Set<string>();
'Street Fighter 6': {
leftCharacter: 'ryu',
rightCharacter: 'chun-li',
},
'TEKKEN 8': {
leftCharacter: 'jin',
rightCharacter: 'kazuya',
},
'2XKO': {
leftCharacter: 'ahri',
rightCharacter: 'yasuo',
},
'Mortal Kombat 1': {
leftCharacter: 'scorpion',
rightCharacter: 'sub-zero',
},
'THE KING OF FIGHTERS XV': {
leftCharacter: 'kyo-kusanagi',
rightCharacter: 'iori-yagami',
},
};
const paletteByGame: Record<string, GamePalette> = { // ── Placeholder SVG ───────────────────────────────────────────────────────────
'Street Fighter 6': ['#f97316', '#b91c1c'],
'TEKKEN 8': ['#2563eb', '#111827'],
'Guilty Gear -Strive-': ['#a855f7', '#312e81'],
'2XKO': ['#7c3aed', '#1d4ed8'],
'Mortal Kombat 1': ['#f59e0b', '#7f1d1d'],
'THE KING OF FIGHTERS XV': ['#0ea5e9', '#1e3a8a'],
};
const toSlug = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''); const toDataUrl = (svg: string): string =>
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
const toDataUrl = (svg: string) => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; const buildPlaceholder = (game: string, character: string, start: string, end: string): string => {
const buildCharacterPlaceholder = (game: string, character: string) => {
const [startColor, endColor] = paletteByGame[game] ?? DEFAULT_PLACEHOLDER_PALETTE;
const initials = character const initials = character
.split(/\s+/) .split(/\s+/)
.map((part) => part[0]) .map((p) => p[0])
.join('') .join('')
.slice(0, MAX_INITIALS) .slice(0, 2)
.toUpperCase(); .toUpperCase();
const svg = ` const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 220" role="img" aria-label="${character}">
<defs> <defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${startColor}"/> <stop offset="0%" stop-color="${start}"/>
<stop offset="100%" stop-color="${endColor}"/> <stop offset="100%" stop-color="${end}"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="480" height="220" fill="url(#bg)" rx="18"/> <rect width="480" height="220" fill="url(#bg)" rx="18"/>
@@ -341,61 +58,55 @@ const buildCharacterPlaceholder = (game: string, character: string) => {
<text x="90" y="130" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="56" font-weight="700">${initials}</text> <text x="90" y="130" text-anchor="middle" fill="#ffffff" font-family="Arial, sans-serif" font-size="56" font-weight="700">${initials}</text>
<text x="170" y="96" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${game}</text> <text x="170" y="96" fill="#e2e8f0" font-family="Arial, sans-serif" font-size="20" font-weight="700">${game}</text>
<text x="170" y="145" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">${character}</text> <text x="170" y="145" fill="#ffffff" font-family="Arial, sans-serif" font-size="38" font-weight="700">${character}</text>
</svg>`; </svg>`.trim();
return toDataUrl(svg.trim()); return toDataUrl(svg);
}; };
const characterImageModules = import.meta.glob('/src/shared/character-images/**/*.{png,jpg,jpeg,webp,avif,svg}', { // ── Pack registration ─────────────────────────────────────────────────────────
eager: true,
import: 'default',
query: '?url',
}) as Record<string, string>;
const resolveImageKey = (path: string): string | null => { /**
const segments = path.split('/'); * Registra un pack instalado para que getCharactersByGame() lo devuelva.
const gameFolder = segments.at(-2); * Llamado por usePackRegistry cuando carga el manifest.json local de un pack.
const filename = segments.at(-1); */
export const registerInstalledPack = (manifest: PackManifest): void => {
const { id, name, palette, characters, defaultPair } = manifest;
if (!gameFolder || !filename) { installedPackCharacters[name] = characters.map((char) => ({
return null; label: char.name,
value: char.slug,
image: `/packs/${id}/characters/${char.slug}.png`,
dlc: char.dlc ?? false,
// Fallback inline por si la imagen no se encuentra en disco
_placeholder: buildPlaceholder(name, char.name, palette.start, palette.end),
}));
if (defaultPair) {
installedPackDefaults[name] = {
leftCharacter: defaultPair.left,
rightCharacter: defaultPair.right,
};
} }
const characterSlug = filename.replace(/\.[^.]+$/, ''); installedPacksRevision.value++;
return `${gameFolder}/${characterSlug}`;
}; };
const characterImageByKey = Object.entries(characterImageModules).reduce<Record<string, string>>((acc, [path, url]) => { /**
const key = resolveImageKey(path); * Elimina un pack del registro en memoria.
if (!key) { * Llamado por usePackRegistry cuando el usuario desinstala un pack.
return acc; */
} export const unregisterInstalledPack = (gameName: string): void => {
delete installedPackCharacters[gameName];
acc[key] = url; delete installedPackDefaults[gameName];
return acc; installedPacksRevision.value++;
}, {});
const getCharacterImage = (game: string, character: string, characterValue: string) => {
const gameSlug = toSlug(game);
const key = `${gameSlug}/${characterValue}`;
return characterImageByKey[key] ?? buildCharacterPlaceholder(game, character);
}; };
export const fightingCharactersByGame: Record<string, FightingCharacterOption[]> = Object.fromEntries( // ── Public API ────────────────────────────────────────────────────────────────
Object.entries(characterNamesByGame).map(([game, characterNames]) => [
game,
characterNames.map((character) => {
const value = toSlug(character);
// Prefer packaged artwork and gracefully fallback to a generated image.
return {
label: character,
value,
image: getCharacterImage(game, character, value),
};
}),
]),
);
export const getCharactersByGame = (game: string) => fightingCharactersByGame[game] ?? []; export const getCharactersByGame = (game: string): FightingCharacterOption[] =>
installedPackCharacters[game] ?? [];
export const getDefaultCharactersByGame = (game: string) => defaultCharacterPairByGame[game]; export const getDefaultCharactersByGame = (
game: string,
): { leftCharacter: string; rightCharacter: string } | undefined =>
installedPackDefaults[game];
+37
View File
@@ -0,0 +1,37 @@
// src/shared/pack-config.ts
// ─────────────────────────────────────────────────────────────────────────────
// Edit ONLY this file to point the pack system at your Gitea instance.
// All other files import their Gitea/NodeCG constants from here.
// ─────────────────────────────────────────────────────────────────────────────
/** Base URL of your Gitea instance — no trailing slash. */
export const GITEA_BASE_URL = 'http://10.0.0.10:3002';
/** Gitea owner (user or organisation) that owns the packs repository. */
export const GITEA_OWNER = 'Pandipipas';
/** Name of the repository that contains all game packs. */
export const GITEA_REPO = 'fighting-game-packs';
/** Branch to pull assets from. */
export const GITEA_BRANCH = 'main';
/**
* NodeCG bundle name.
* Must match the "name" field in your package.json / nodecg config.
*/
export const BUNDLE_NAME = 'scoreko-dev';
// ── Derived URL helpers (do not edit below this line) ────────────────────────
/** Returns the Gitea raw-file URL for any repo-relative path. */
export const getGiteaRawUrl = (repoPath) => `${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
/** URL of the master registry file that lists every available pack. */
export const REGISTRY_URL = getGiteaRawUrl('registry.json');
/** Returns the URL for a specific pack's manifest.json. */
export const getManifestUrl = (packId) => getGiteaRawUrl(`${packId}/manifest.json`);
/** Returns the URL for a pack's logo. */
export const getPackLogoUrl = (packId) => getGiteaRawUrl(`${packId}/logo.png`);
/**
* Returns the URL for a specific character image stored in the Gitea repo.
* Used during download; at runtime installed packs are served by NodeCG.
*/
export const getCharacterImageRepoUrl = (packId, slug, ext) => getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`);
/**
* Returns the runtime URL for a character image from an *installed* (downloaded) pack.
* NodeCG serves everything under assets/ at /assets/<bundleName>/.
*/
export const getInstalledCharacterImageUrl = (packId, slug, ext = 'png') => `/assets/${BUNDLE_NAME}/packs/${packId}/characters/${slug}.${ext}`;
+54
View File
@@ -0,0 +1,54 @@
// src/shared/pack-config.ts
// ─────────────────────────────────────────────────────────────────────────────
// Edit ONLY this file to point the pack system at your Gitea instance.
// All other files import their Gitea/NodeCG constants from here.
// ─────────────────────────────────────────────────────────────────────────────
/** Base URL of your Gitea instance — no trailing slash. */
export const GITEA_BASE_URL = 'http://10.0.0.10:3002';
/** Gitea owner (user or organisation) that owns the packs repository. */
export const GITEA_OWNER = 'Pandipipas';
/** Name of the repository that contains all game packs. */
export const GITEA_REPO = 'fighting-game-packs';
/** Branch to pull assets from. */
export const GITEA_BRANCH = 'main';
/**
* NodeCG bundle name.
* Must match the "name" field in your package.json / nodecg config.
*/
export const BUNDLE_NAME = 'scoreko-dev';
// ── Derived URL helpers (do not edit below this line) ────────────────────────
/** Returns the Gitea raw-file URL for any repo-relative path. */
export const getGiteaRawUrl = (repoPath: string): string =>
`${GITEA_BASE_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${repoPath}`;
/** URL of the master registry file that lists every available pack. */
export const REGISTRY_URL = getGiteaRawUrl('registry.json');
/** Returns the URL for a specific pack's manifest.json. */
export const getManifestUrl = (packId: string): string =>
getGiteaRawUrl(`${packId}/manifest.json`);
/** Returns the URL for a pack's logo. */
export const getPackLogoUrl = (packId: string): string =>
getGiteaRawUrl(`${packId}/logo.png`);
/**
* Returns the URL for a specific character image stored in the Gitea repo.
* Used during download; at runtime installed packs are served by NodeCG.
*/
export const getCharacterImageRepoUrl = (packId: string, slug: string, ext: string): string =>
getGiteaRawUrl(`${packId}/characters/${slug}.${ext}`);
/**
* Returns the runtime URL for a character image from an *installed* (downloaded) pack.
* NodeCG serves everything under assets/ at /assets/<bundleName>/.
*/
export const getInstalledCharacterImageUrl = (packId: string, slug: string, ext = 'png'): string =>
`/packs/${packId}/characters/${slug}.${ext}`;
+6
View File
@@ -0,0 +1,6 @@
// src/shared/pack-types.ts
// ─────────────────────────────────────────────────────────────────────────────
// Shared between the NodeCG extension (Node.js) and the dashboard (browser).
// Do NOT import anything that is browser-only or Node-only from this file.
// ─────────────────────────────────────────────────────────────────────────────
export {};
+89
View File
@@ -0,0 +1,89 @@
// src/shared/pack-types.ts
// ─────────────────────────────────────────────────────────────────────────────
// Shared between the NodeCG extension (Node.js) and the dashboard (browser).
// Do NOT import anything that is browser-only or Node-only from this file.
// ─────────────────────────────────────────────────────────────────────────────
/** A single character entry inside a pack manifest. */
export interface PackCharacter {
/** Display name, e.g. "Chun-Li" */
name: string;
/** URL-safe slug that matches the image filename, e.g. "chun-li" */
slug: string;
/** True when the character is paid DLC (shown with the DLC badge in the UI). */
dlc?: boolean;
/** Approximate compressed size of the character image file in bytes. */
sizeBytes: number;
}
/**
* Lightweight entry in the top-level registry.json.
* Enough for the UI to render the game list and the download dialog preview
* without having to fetch the full manifest.
*/
export interface PackRegistryEntry {
/** Unique identifier — must match the folder name in the repo, e.g. "street-fighter-6". */
id: string;
/** Human-readable game title shown in the selector, e.g. "Street Fighter 6". */
name: string;
/** Semantic version of this pack, e.g. "1.0.0". Bump when adding/updating characters. */
version: string;
/** Total download size (sum of all character images + logo) in bytes. */
totalSizeBytes: number;
/** Repo-relative path to the game's logo image, e.g. "street-fighter-6/logo.png". */
logoPath: string;
/** Pre-computed character count so the dialog can show it without loading the manifest. */
characterCount: number;
/** Gradient used for placeholder images when a character has no artwork. */
palette: { start: string; end: string };
/**
* True when the pack ships inside the application bundle (bundled via Vite's
* import.meta.glob). Bundled packs are always "installed" and never show the
* download button, but they still appear in the registry so the app can detect
* updates (version mismatch between bundle and registry).
*/
bundled: boolean;
}
/** Full pack data — lives at <packId>/manifest.json in the repo. */
export interface PackManifest {
/** Must match PackRegistryEntry.id and the folder name. */
id: string;
/** Must match PackRegistryEntry.name. */
name: string;
version: string;
palette: { start: string; end: string };
/** Default characters pre-selected when this game is first chosen. */
defaultPair?: { left: string; right: string };
/** Full character roster, in the order they should appear in the selector. */
characters: PackCharacter[];
}
/** Top-level registry.json structure. */
export interface PackRegistry {
schemaVersion: number;
updatedAt: string;
packs: PackRegistryEntry[];
}
/** Tracks the download lifecycle of a single pack. */
export interface PackDownloadState {
status: 'idle' | 'fetching-manifest' | 'downloading' | 'done' | 'error';
/** Progress percentage 0100. */
progress: number;
error?: string;
}
/** Shape of the option objects surfaced by usePackRegistry.allGameOptions. */
export interface GameSelectOption {
/** Display label for the QSelect. */
label: string;
/** Value stored in the scoreboard (equals PackRegistryEntry.name for installed games). */
value: string;
/** Whether the pack can be used right now (bundled or already downloaded). */
available: boolean;
/** Mirrors PackRegistryEntry so the download dialog can be populated inline. */
registryEntry: PackRegistryEntry;
/** Present when there is a newer version of this pack available in the registry. */
updateInfo?: { installedVersion: string; latestVersion: string };
}