Add start.gg tournament and player import integration

This commit is contained in:
Pandipipas
2026-02-15 23:57:31 +01:00
parent 45fd2e2deb
commit a83f633506
3 changed files with 454 additions and 1 deletions
+228 -1
View File
@@ -4,7 +4,7 @@ import { useHead } from '@unhead/vue';
defineOptions({ name: 'PlayersView' });
import type { QTableColumn } from 'quasar';
import { computed, reactive, ref, watch } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { countryOptions, getCountryLabel } from '../../../shared/countries';
import type { Schemas } from '../../../types';
import { usePlayersStore } from '../stores/players';
@@ -18,6 +18,19 @@ interface PlayerRow extends Player {
id: string;
}
interface StartGGTournament {
id: number;
name: string;
slug: string;
startAt: number | null;
}
interface StartGGImportedPlayer extends Player {
id: string;
}
const STARTGG_TOKEN_STORAGE_KEY = 'scoreko-dev.startgg-token';
const playersStore = usePlayersStore();
const rows = computed<PlayerRow[]>(() => playersStore.rows);
@@ -73,6 +86,98 @@ watch(
{ immediate: true },
);
const startGGToken = ref(localStorage.getItem(STARTGG_TOKEN_STORAGE_KEY) ?? '');
const recentTournaments = ref<StartGGTournament[]>([]);
const loadingTournaments = ref(false);
const tournamentsError = ref('');
const isImportDialogOpen = ref(false);
const loadingTournamentPlayers = ref(false);
const selectedTournament = ref<StartGGTournament | null>(null);
const startGGPlayers = ref<StartGGImportedPlayer[]>([]);
const selectedStartGGPlayerIds = ref<string[]>([]);
watch(startGGToken, (value) => {
localStorage.setItem(STARTGG_TOKEN_STORAGE_KEY, value);
});
const sendNodeCGMessage = <T>(messageName: string, payload: unknown): Promise<T> =>
new Promise((resolve, reject) => {
nodecg.sendMessage(messageName, payload, (error, response) => {
if (error) {
reject(new Error(String(error)));
return;
}
resolve(response as T);
});
});
const loadRecentTournaments = async () => {
const token = startGGToken.value.trim();
if (!token) {
tournamentsError.value = 'Añade tu token de start.gg para cargar torneos.';
recentTournaments.value = [];
return;
}
tournamentsError.value = '';
loadingTournaments.value = true;
try {
const tournaments = await sendNodeCGMessage<StartGGTournament[]>('startgg:fetchRecentTournaments', {
token,
});
recentTournaments.value = tournaments;
if (!tournaments.length) {
tournamentsError.value = 'No hay torneos recientes para esta cuenta.';
}
} catch (error) {
tournamentsError.value = error instanceof Error ? error.message : 'No se pudieron cargar torneos.';
recentTournaments.value = [];
} finally {
loadingTournaments.value = false;
}
};
const openStartGGImportDialog = async (tournament: StartGGTournament) => {
selectedTournament.value = tournament;
isImportDialogOpen.value = true;
loadingTournamentPlayers.value = true;
selectedStartGGPlayerIds.value = [];
startGGPlayers.value = [];
try {
const importedPlayers = await sendNodeCGMessage<StartGGImportedPlayer[]>('startgg:fetchTournamentPlayers', {
token: startGGToken.value.trim(),
slug: tournament.slug,
});
startGGPlayers.value = importedPlayers;
selectedStartGGPlayerIds.value = importedPlayers.map((player) => player.id);
} catch (error) {
const message = error instanceof Error ? error.message : 'No se pudieron cargar jugadores';
window.alert(message);
isImportDialogOpen.value = false;
} finally {
loadingTournamentPlayers.value = false;
}
};
const importSelectedStartGGPlayers = () => {
const selectedPlayers = startGGPlayers.value.filter((player) =>
selectedStartGGPlayerIds.value.includes(player.id),
);
selectedPlayers.forEach((player) => {
playersStore.upsertPlayer(player.id, {
gamertag: player.gamertag,
name: player.name,
team: player.team,
country: player.country,
twitter: player.twitter,
});
});
isImportDialogOpen.value = false;
};
const generateId = () => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
@@ -141,6 +246,12 @@ const handleImport = async (event: Event) => {
}
}
};
onMounted(() => {
if (startGGToken.value.trim()) {
void loadRecentTournaments();
}
});
</script>
<template>
@@ -159,6 +270,72 @@ const handleImport = async (event: Event) => {
/>
</div>
<QCard
flat
bordered
class="q-pa-md"
>
<div class="text-h6 q-mb-sm">
Integración start.gg
</div>
<div class="text-caption q-mb-md">
Pega tu token personal de start.gg para cargar automáticamente tus torneos recientes y sus inscritos.
</div>
<div class="row q-col-gutter-sm items-center">
<div class="col-12 col-md-7">
<QInput
v-model="startGGToken"
label="start.gg API Token"
dense
outlined
type="password"
/>
</div>
<div class="col-auto">
<QBtn
color="secondary"
icon="sync"
label="Cargar torneos"
:loading="loadingTournaments"
@click="loadRecentTournaments"
/>
</div>
</div>
<div
v-if="tournamentsError"
class="text-negative q-mt-sm"
>
{{ tournamentsError }}
</div>
<QList
v-if="recentTournaments.length"
bordered
separator
class="q-mt-md startgg-tournaments-list"
>
<QItem
v-for="tournament in recentTournaments"
:key="tournament.id"
clickable
>
<QItemSection>
<QItemLabel>{{ tournament.name }}</QItemLabel>
<QItemLabel caption>
{{ tournament.slug }}
</QItemLabel>
</QItemSection>
<QItemSection side>
<QBtn
color="primary"
unelevated
label="Importar jugadores"
@click.stop="openStartGGImportDialog(tournament)"
/>
</QItemSection>
</QItem>
</QList>
</QCard>
<div class="row items-center q-gutter-sm q-mb-md">
<QInput
v-model="filter"
@@ -222,6 +399,51 @@ const handleImport = async (event: Event) => {
</template>
</QTable>
<QDialog v-model="isImportDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">
Importar desde {{ selectedTournament?.name || 'start.gg' }}
</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div
v-if="loadingTournamentPlayers"
class="row items-center q-gutter-sm"
>
<QSpinner />
<span>Cargando inscritos...</span>
</div>
<div v-else>
<QOptionGroup
v-model="selectedStartGGPlayerIds"
type="checkbox"
:options="startGGPlayers.map((player) => ({
label: `${player.gamertag}${player.team ? ` (${player.team})` : ''}${player.country ? ` - ${getCountryLabel(player.country)}` : ''}`,
value: player.id,
}))"
/>
</div>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn
flat
label="Cancelar"
color="secondary"
@click="isImportDialogOpen = false"
/>
<QBtn
color="primary"
label="Importar seleccionados"
:disable="!selectedStartGGPlayerIds.length"
@click="importSelectedStartGGPlayers"
/>
</QCardActions>
</QCard>
</QDialog>
<QDialog v-model="isDialogOpen">
<QCard class="players-dialog">
<QCardSection>
@@ -324,6 +546,11 @@ const handleImport = async (event: Event) => {
width: min(720px, 90vw);
}
.startgg-tournaments-list {
max-height: 280px;
overflow: auto;
}
.players-underlined-field :deep(.q-field__control) {
min-height: 28px;
padding: 0;
+1
View File
@@ -9,4 +9,5 @@ export default async (nodecg: NodeCGServerAPI) => {
set(nodecg); // set nodecg "context" before anything else
await import('./util/replicants.js'); // make sure replicants are set up
await import('./example.js');
await import('./startgg.js');
};
+225
View File
@@ -0,0 +1,225 @@
import { getData, type CountryRecord } from 'country-list';
import { nodecg } from './util/nodecg.js';
const STARTGG_ENDPOINT = 'https://api.start.gg/gql/alpha';
const RECENT_TOURNAMENTS_LIMIT = 12;
const PARTICIPANTS_PAGE_SIZE = 120;
interface StartGGGraphQLResponse<T> {
data?: T;
errors?: Array<{ message?: string }>;
}
interface RecentTournament {
id: number;
name: string;
slug: string;
startAt: number | null;
}
interface ImportedPlayer {
id: string;
gamertag: string;
name: string;
team: string;
country: string;
twitter: string;
}
const requestStartGG = async <T>(query: string, variables: Record<string, unknown>, token: string): Promise<T> => {
const response = await fetch(STARTGG_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(`start.gg responded with ${response.status}`);
}
const payload = (await response.json()) as StartGGGraphQLResponse<T>;
if (payload.errors?.length) {
throw new Error(payload.errors[0]?.message || 'Unknown start.gg error');
}
if (!payload.data) {
throw new Error('No data returned by start.gg');
}
return payload.data;
};
const countryByCode = new Set(getData().map((country: CountryRecord) => country.code.toUpperCase()));
const countryByName = new Map(getData().map((country: CountryRecord) => [country.name.toLowerCase(), country.code.toUpperCase()]));
const resolveCountryCodeFromStartGG = (country: string | null | undefined): string => {
const raw = (country || '').trim();
if (!raw) {
return '';
}
const upper = raw.toUpperCase();
if (countryByCode.has(upper)) {
return upper;
}
return countryByName.get(raw.toLowerCase()) ?? '';
};
const sendAck = (ack: unknown, error: string | null, response?: unknown) => {
if (typeof ack !== 'function') {
return;
}
ack(error, response);
};
nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) => {
const token = typeof payload === 'object' && payload !== null && 'token' in payload
? String((payload as { token?: string }).token || '').trim()
: '';
if (!token) {
sendAck(ack, 'Missing start.gg API token');
return;
}
const query = `
query RecentTournaments($perPage: Int!) {
currentUser {
tournaments(query: { perPage: $perPage }) {
nodes {
id
name
slug
startAt
}
}
}
}
`;
try {
const data = await requestStartGG<{
currentUser: { tournaments: { nodes: RecentTournament[] } } | null;
}>(query, { perPage: RECENT_TOURNAMENTS_LIMIT }, token);
const tournaments = data.currentUser?.tournaments.nodes
.filter((item) => item.slug)
.sort((a, b) => (b.startAt ?? 0) - (a.startAt ?? 0))
.map((item) => ({
id: item.id,
name: item.name,
slug: item.slug,
startAt: item.startAt,
})) ?? [];
sendAck(ack, null, tournaments);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while loading tournaments';
sendAck(ack, message);
}
});
nodecg.listenFor('startgg:fetchTournamentPlayers', async (payload: unknown, ack) => {
const candidate = typeof payload === 'object' && payload !== null ? payload as {
token?: string;
slug?: string;
} : {};
const token = String(candidate.token || '').trim();
const slug = String(candidate.slug || '').trim();
if (!token) {
sendAck(ack, 'Missing start.gg API token');
return;
}
if (!slug) {
sendAck(ack, 'Missing tournament slug');
return;
}
const query = `
query TournamentParticipants($slug: String!, $page: Int!, $perPage: Int!) {
tournament(slug: $slug) {
participants(query: { page: $page, perPage: $perPage }) {
pageInfo {
totalPages
}
nodes {
id
gamerTag
prefix
user {
location {
country
}
}
}
}
}
}
`;
try {
let currentPage = 1;
let totalPages = 1;
const playersMap: Record<string, ImportedPlayer> = {};
while (currentPage <= totalPages) {
const data = await requestStartGG<{
tournament: {
participants: {
pageInfo: { totalPages: number };
nodes: Array<{
id: number;
gamerTag: string | null;
prefix: string | null;
user: {
location: {
country: string | null;
} | null;
} | null;
}>;
};
} | null;
}>(query, {
slug,
page: currentPage,
perPage: PARTICIPANTS_PAGE_SIZE,
}, token);
if (!data.tournament) {
throw new Error('Tournament not found');
}
totalPages = Math.max(data.tournament.participants.pageInfo.totalPages || 1, 1);
data.tournament.participants.nodes.forEach((participant) => {
const playerId = String(participant.id);
const gamertag = (participant.gamerTag || '').trim();
if (!gamertag) {
return;
}
const country = resolveCountryCodeFromStartGG(participant.user?.location?.country);
playersMap[playerId] = {
id: playerId,
gamertag,
name: gamertag,
team: (participant.prefix || '').trim(),
country,
twitter: '',
};
});
currentPage += 1;
}
sendAck(ack, null, Object.values(playersMap));
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error while importing players';
sendAck(ack, message);
}
});