mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
Add start.gg tournament and player import integration
This commit is contained in:
@@ -4,7 +4,7 @@ import { useHead } from '@unhead/vue';
|
|||||||
defineOptions({ name: 'PlayersView' });
|
defineOptions({ name: 'PlayersView' });
|
||||||
|
|
||||||
import type { QTableColumn } from 'quasar';
|
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 { countryOptions, getCountryLabel } from '../../../shared/countries';
|
||||||
import type { Schemas } from '../../../types';
|
import type { Schemas } from '../../../types';
|
||||||
import { usePlayersStore } from '../stores/players';
|
import { usePlayersStore } from '../stores/players';
|
||||||
@@ -18,6 +18,19 @@ interface PlayerRow extends Player {
|
|||||||
id: string;
|
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 playersStore = usePlayersStore();
|
||||||
const rows = computed<PlayerRow[]>(() => playersStore.rows);
|
const rows = computed<PlayerRow[]>(() => playersStore.rows);
|
||||||
|
|
||||||
@@ -73,6 +86,98 @@ watch(
|
|||||||
{ immediate: true },
|
{ 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 = () => {
|
const generateId = () => {
|
||||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -141,6 +246,12 @@ const handleImport = async (event: Event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (startGGToken.value.trim()) {
|
||||||
|
void loadRecentTournaments();
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -159,6 +270,72 @@ const handleImport = async (event: Event) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="row items-center q-gutter-sm q-mb-md">
|
||||||
<QInput
|
<QInput
|
||||||
v-model="filter"
|
v-model="filter"
|
||||||
@@ -222,6 +399,51 @@ const handleImport = async (event: Event) => {
|
|||||||
</template>
|
</template>
|
||||||
</QTable>
|
</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">
|
<QDialog v-model="isDialogOpen">
|
||||||
<QCard class="players-dialog">
|
<QCard class="players-dialog">
|
||||||
<QCardSection>
|
<QCardSection>
|
||||||
@@ -324,6 +546,11 @@ const handleImport = async (event: Event) => {
|
|||||||
width: min(720px, 90vw);
|
width: min(720px, 90vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.startgg-tournaments-list {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.players-underlined-field :deep(.q-field__control) {
|
.players-underlined-field :deep(.q-field__control) {
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -9,4 +9,5 @@ export default async (nodecg: NodeCGServerAPI) => {
|
|||||||
set(nodecg); // set nodecg "context" before anything else
|
set(nodecg); // set nodecg "context" before anything else
|
||||||
await import('./util/replicants.js'); // make sure replicants are set up
|
await import('./util/replicants.js'); // make sure replicants are set up
|
||||||
await import('./example.js');
|
await import('./example.js');
|
||||||
|
await import('./startgg.js');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user