Compare commits
9 Commits
27c0298ca2
..
0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c270feb5b | |||
| 618d18d8fb | |||
| 0bc6f60b2c | |||
| 88aeedb5ff | |||
| 04f2c2037a | |||
| fd4201a882 | |||
| 787de05034 | |||
| 67d9d20b56 | |||
| 79f6653d94 |
@@ -142,3 +142,4 @@ dist
|
|||||||
/db/
|
/db/
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
/scoreko-electron-dev/
|
/scoreko-electron-dev/
|
||||||
|
/packs/
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 side’s score.',
|
||||||
settingsShortcutLeftIncrementLabel: 'P1 score +1',
|
settingsShortcutLeftIncrementLabel: 'P1 score +1',
|
||||||
settingsShortcutLeftIncrementHint: 'Increases left player score by one.',
|
settingsShortcutLeftIncrementHint: 'Increases the left player’s score by one.',
|
||||||
settingsShortcutLeftDecrementLabel: 'P1 score -1',
|
settingsShortcutLeftDecrementLabel: 'P1 score -1',
|
||||||
settingsShortcutLeftDecrementHint: 'Decreases left player score by one.',
|
settingsShortcutLeftDecrementHint: 'Decreases the left player’s score by one.',
|
||||||
settingsShortcutRightIncrementLabel: 'P2 score +1',
|
settingsShortcutRightIncrementLabel: 'P2 score +1',
|
||||||
settingsShortcutRightIncrementHint: 'Increases right player score by one.',
|
settingsShortcutRightIncrementHint: 'Increases the right player’s score by one.',
|
||||||
settingsShortcutRightDecrementLabel: 'P2 score -1',
|
settingsShortcutRightDecrementLabel: 'P2 score -1',
|
||||||
settingsShortcutRightDecrementHint: 'Decreases right player score by one.',
|
settingsShortcutRightDecrementHint: 'Decreases the right player’s score by one.',
|
||||||
settingsShortcutReset: 'Reset shortcuts',
|
settingsShortcutReset: 'Reset shortcuts',
|
||||||
settingsShortcutRecordingHint: 'Press the desired shortcut now (example: Alt+1).',
|
settingsShortcutRecordingHint: 'Press the desired shortcut now (for example: Alt+1).',
|
||||||
|
settingsShortcutConflictWarning: 'This shortcut is already assigned to another action.',
|
||||||
|
settingsShortcutStartRecording: 'Start recording shortcut',
|
||||||
|
settingsShortcutStopRecording: 'Stop recording shortcut',
|
||||||
|
settingsShortcutResetSingle: 'Reset this shortcut',
|
||||||
|
settingsIntegrationsTitle: 'Integrations',
|
||||||
|
settingsIntegrationsDescription: 'Connect your tournament platform accounts to import players directly from brackets.',
|
||||||
|
settingsDisconnect: 'Disconnect',
|
||||||
|
settingsNotConnected: 'Not connected',
|
||||||
|
|
||||||
|
// ── Language ─────────────────────────────────────────────────────────────
|
||||||
languageEnglish: 'English',
|
languageEnglish: 'English',
|
||||||
languageSpanish: 'Spanish',
|
languageSpanish: 'Spanish',
|
||||||
|
|
||||||
|
// ── Scoreboard ───────────────────────────────────────────────────────────
|
||||||
scoreboardUnassigned: '(Unassigned)',
|
scoreboardUnassigned: '(Unassigned)',
|
||||||
scoreboardLeft: 'Left',
|
scoreboardLeft: 'Left',
|
||||||
scoreboardRight: 'Right',
|
scoreboardRight: 'Right',
|
||||||
scoreboardPreview: 'preview',
|
scoreboardPreview: 'Preview',
|
||||||
scoreboardLeftImage: 'Left image',
|
scoreboardLeftImage: 'Left image',
|
||||||
scoreboardRightImage: 'Right image',
|
scoreboardRightImage: 'Right image',
|
||||||
scoreboardLabelCharacter: 'Character',
|
scoreboardLabelCharacter: 'Character',
|
||||||
@@ -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 Electron’s main process and publish signed artifacts per platform.',
|
||||||
aboutUnknownReleaseError: 'Unknown error while checking releases.',
|
aboutUnknownReleaseError: 'Unknown error while checking releases.',
|
||||||
aboutGitHubStatusError: 'GitHub responded with status',
|
aboutGitHubStatusError: 'GitHub responded with status',
|
||||||
|
aboutChangelog: 'Changelog',
|
||||||
|
aboutTechStackTitle: 'Tech stack',
|
||||||
|
|
||||||
|
// ── Graphics ─────────────────────────────────────────────────────────────
|
||||||
graphicsTitle: 'Graphics',
|
graphicsTitle: 'Graphics',
|
||||||
graphicsDescription: 'Bundle graphics controls and status.',
|
graphicsDescription: 'Controls and status for bundle graphics.',
|
||||||
graphicsNoConfigured: 'There are no graphics configured in this bundle.',
|
graphicsNoConfigured: 'There are no graphics configured in this bundle.',
|
||||||
graphicsCopyUrl: 'Copy URL',
|
graphicsCopyUrl: 'Copy URL',
|
||||||
graphicsDragObs: 'Drag into OBS',
|
graphicsDragObs: 'Drag into OBS',
|
||||||
graphicsScoreboard: 'Scoreboard',
|
graphicsScoreboard: 'Scoreboard',
|
||||||
graphicsCommentary: 'Commentary',
|
graphicsCommentary: 'Commentators',
|
||||||
graphicsSkinLabel: 'Skin',
|
graphicsSkinLabel: 'Theme',
|
||||||
commentaryTitle: 'Commentary',
|
graphicsCopied: 'URL copied to clipboard',
|
||||||
|
graphicsOpenBrowser: 'Open in browser',
|
||||||
|
|
||||||
|
// ── Commentary ───────────────────────────────────────────────────────────
|
||||||
|
commentaryTitle: 'Commentators',
|
||||||
commentaryCommentator1: 'Commentator #1',
|
commentaryCommentator1: 'Commentator #1',
|
||||||
commentaryCommentator2: 'Commentator #2',
|
commentaryCommentator2: 'Commentator #2',
|
||||||
commentaryTwitterText: '@Twitter / Text',
|
commentaryTwitterText: 'Twitter / Text',
|
||||||
|
commentaryTwitterMaxLength: 'Twitter character limit exceeded',
|
||||||
|
commentaryTwitterInvalidChars: 'Invalid characters in Twitter text',
|
||||||
|
commentarySwap: 'Swap commentators',
|
||||||
|
commentaryClear: 'Clear commentators',
|
||||||
|
|
||||||
|
// ── Bracket ──────────────────────────────────────────────────────────────
|
||||||
bracketTitle: 'Bracket',
|
bracketTitle: 'Bracket',
|
||||||
bracketStage: 'Stage',
|
bracketStage: 'Stage',
|
||||||
bracketSide: 'Bracket side',
|
bracketSide: 'Bracket side',
|
||||||
bracketCustomProgress: 'Custom progress',
|
bracketCustomProgress: 'Custom progress',
|
||||||
bracketPreview: 'Preview',
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 828 KiB |
|
After Width: | Height: | Size: 515 KiB |
|
After Width: | Height: | Size: 605 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 762 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 776 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 515 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 299 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 277 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 516 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 240 KiB |
|
After Width: | Height: | Size: 716 KiB |
|
After Width: | Height: | Size: 414 KiB |
|
After Width: | Height: | Size: 804 KiB |
|
After Width: | Height: | Size: 492 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 527 KiB |
|
Before Width: | Height: | Size: 4.4 MiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 3.9 MiB After Width: | Height: | Size: 311 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 441 KiB |
|
Before Width: | Height: | Size: 3.9 MiB After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 586 KiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 16 MiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 920 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 744 KiB |
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 5.2 MiB After Width: | Height: | Size: 898 KiB |
|
Before Width: | Height: | Size: 4.9 MiB After Width: | Height: | Size: 12 MiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 522 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 611 KiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 580 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 877 KiB |
|
Before Width: | Height: | Size: 5.6 MiB After Width: | Height: | Size: 967 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 826 KiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 561 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 775 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 476 KiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 708 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 464 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 584 KiB |
|
Before Width: | Height: | Size: 5.0 MiB After Width: | Height: | Size: 965 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 657 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 772 KiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 673 KiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 577 KiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 552 KiB |
@@ -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];
|
||||||
|
|||||||
@@ -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}`;
|
||||||
@@ -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}`;
|
||||||
@@ -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 {};
|
||||||
@@ -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 0–100. */
|
||||||
|
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 };
|
||||||
|
}
|
||||||