From a83f633506d162411575b88bf67188bef69092e9 Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Sun, 15 Feb 2026 23:57:31 +0100 Subject: [PATCH 01/20] Add start.gg tournament and player import integration --- src/dashboard/scoreko-dev/views/Players.vue | 229 +++++++++++++++++++- src/extension/index.ts | 1 + src/extension/startgg.ts | 225 +++++++++++++++++++ 3 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 src/extension/startgg.ts diff --git a/src/dashboard/scoreko-dev/views/Players.vue b/src/dashboard/scoreko-dev/views/Players.vue index 668b466..24932b1 100644 --- a/src/dashboard/scoreko-dev/views/Players.vue +++ b/src/dashboard/scoreko-dev/views/Players.vue @@ -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(() => playersStore.rows); @@ -73,6 +86,98 @@ watch( { immediate: true }, ); +const startGGToken = ref(localStorage.getItem(STARTGG_TOKEN_STORAGE_KEY) ?? ''); +const recentTournaments = ref([]); +const loadingTournaments = ref(false); +const tournamentsError = ref(''); +const isImportDialogOpen = ref(false); +const loadingTournamentPlayers = ref(false); +const selectedTournament = ref(null); +const startGGPlayers = ref([]); +const selectedStartGGPlayerIds = ref([]); + +watch(startGGToken, (value) => { + localStorage.setItem(STARTGG_TOKEN_STORAGE_KEY, value); +}); + +const sendNodeCGMessage = (messageName: string, payload: unknown): Promise => + 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('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('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(); + } +}); + + + +
+ Importar desde {{ selectedTournament?.name || 'start.gg' }} +
+
+ + +
+ + Cargando inscritos... +
+
+ +
+
+ + + + + +
+
+ @@ -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; diff --git a/src/extension/index.ts b/src/extension/index.ts index 2859808..f890cdf 100644 --- a/src/extension/index.ts +++ b/src/extension/index.ts @@ -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'); }; diff --git a/src/extension/startgg.ts b/src/extension/startgg.ts new file mode 100644 index 0000000..7e73bd4 --- /dev/null +++ b/src/extension/startgg.ts @@ -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 { + 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 (query: string, variables: Record, token: string): Promise => { + 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; + 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 = {}; + + 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); + } +}); From 165482a7e0a763614da0a30bcfb23b1fb8155aeb Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:07:55 +0100 Subject: [PATCH 02/20] Adjust start.gg layout and admin-only tournament listing --- src/dashboard/scoreko-dev/views/Players.vue | 257 +++++++++++--------- src/extension/startgg.ts | 2 +- 2 files changed, 139 insertions(+), 120 deletions(-) diff --git a/src/dashboard/scoreko-dev/views/Players.vue b/src/dashboard/scoreko-dev/views/Players.vue index 24932b1..8954688 100644 --- a/src/dashboard/scoreko-dev/views/Players.vue +++ b/src/dashboard/scoreko-dev/views/Players.vue @@ -270,135 +270,141 @@ onMounted(() => { /> - -
- Integración start.gg -
-
- Pega tu token personal de start.gg para cargar automáticamente tus torneos recientes y sus inscritos. -
-
-
+
+
+
-
-
+ placeholder="Search..." + class="players-search players-underlined-field" + clearable + > + + + +
-
-
- {{ tournamentsError }} -
- - - - {{ tournament.name }} - - {{ tournament.slug }} - - - - - - - - + + +
-
- - - - - - +
+ +
+ Integración start.gg +
+
+ Pega tu token personal de start.gg para cargar automáticamente tus torneos creados o donde eres admin. +
+
+
+ +
+
+ +
+
+
+ {{ tournamentsError }} +
+ + + + {{ tournament.name }} + + {{ tournament.slug }} + + + + + + + +
+
- - - - @@ -541,6 +547,19 @@ onMounted(() => { min-width: 240px; } +.players-content { + align-items: flex-start; +} + +.players-main-column { + min-width: 0; + flex: 1 1 auto; +} + +.players-startgg-column { + min-width: 320px; +} + .players-dialog { min-width: 320px; width: min(720px, 90vw); diff --git a/src/extension/startgg.ts b/src/extension/startgg.ts index 7e73bd4..ceabe9e 100644 --- a/src/extension/startgg.ts +++ b/src/extension/startgg.ts @@ -89,7 +89,7 @@ nodecg.listenFor('startgg:fetchRecentTournaments', async (payload: unknown, ack) const query = ` query RecentTournaments($perPage: Int!) { currentUser { - tournaments(query: { perPage: $perPage }) { + tournaments(query: { perPage: $perPage, filter: { tournamentView: "admin" } }) { nodes { id name From 78dc137679a2632e7ae82281857b0e2d30030b17 Mon Sep 17 00:00:00 2001 From: Pandipipas <62224708+Pandipipas@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:22:54 +0100 Subject: [PATCH 03/20] Add start.gg OAuth login flow for local users --- src/dashboard/scoreko-dev/views/Players.vue | 92 ++++++- src/extension/startgg.ts | 291 ++++++++++++++++++++ 2 files changed, 381 insertions(+), 2 deletions(-) diff --git a/src/dashboard/scoreko-dev/views/Players.vue b/src/dashboard/scoreko-dev/views/Players.vue index 8954688..00ad156 100644 --- a/src/dashboard/scoreko-dev/views/Players.vue +++ b/src/dashboard/scoreko-dev/views/Players.vue @@ -4,7 +4,7 @@ import { useHead } from '@unhead/vue'; defineOptions({ name: 'PlayersView' }); import type { QTableColumn } from 'quasar'; -import { computed, onMounted, reactive, ref, watch } from 'vue'; +import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; import { countryOptions, getCountryLabel } from '../../../shared/countries'; import type { Schemas } from '../../../types'; import { usePlayersStore } from '../stores/players'; @@ -96,6 +96,21 @@ const selectedTournament = ref(null); const startGGPlayers = ref([]); const selectedStartGGPlayerIds = ref([]); +const oauthLoading = ref(false); +const oauthSessionId = ref(''); +let oauthPollingTimer: ReturnType | null = null; + +interface OAuthSessionResponse { + sessionId: string; + authUrl: string; +} + +interface OAuthStatusResponse { + status: 'pending' | 'completed' | 'error' | 'expired'; + token?: string; + error?: string; +} + watch(startGGToken, (value) => { localStorage.setItem(STARTGG_TOKEN_STORAGE_KEY, value); }); @@ -111,6 +126,66 @@ const sendNodeCGMessage = (messageName: string, payload: unknown): Promise }); }); +const clearOAuthPolling = () => { + if (oauthPollingTimer) { + clearInterval(oauthPollingTimer); + oauthPollingTimer = null; + } +}; + +const checkOAuthStatus = async () => { + if (!oauthSessionId.value) { + return; + } + + try { + const status = await sendNodeCGMessage('startgg:getOAuthSessionStatus', { + sessionId: oauthSessionId.value, + }); + + if (status.status === 'completed' && status.token) { + startGGToken.value = status.token; + oauthLoading.value = false; + clearOAuthPolling(); + oauthSessionId.value = ''; + tournamentsError.value = ''; + await loadRecentTournaments(); + return; + } + + if (status.status === 'error' || status.status === 'expired') { + oauthLoading.value = false; + clearOAuthPolling(); + oauthSessionId.value = ''; + tournamentsError.value = status.error || 'No se pudo completar el login OAuth con start.gg.'; + } + } catch (error) { + oauthLoading.value = false; + clearOAuthPolling(); + oauthSessionId.value = ''; + tournamentsError.value = error instanceof Error ? error.message : 'No se pudo verificar el estado OAuth.'; + } +}; + +const connectWithStartGGOAuth = async () => { + oauthLoading.value = true; + tournamentsError.value = ''; + clearOAuthPolling(); + + try { + const session = await sendNodeCGMessage('startgg:createOAuthSession', {}); + oauthSessionId.value = session.sessionId; + window.open(session.authUrl, '_blank', 'noopener,noreferrer'); + + oauthPollingTimer = setInterval(() => { + void checkOAuthStatus(); + }, 1500); + } catch (error) { + oauthLoading.value = false; + tournamentsError.value = error instanceof Error ? error.message : 'No se pudo iniciar OAuth con start.gg.'; + } +}; + const loadRecentTournaments = async () => { const token = startGGToken.value.trim(); if (!token) { @@ -252,6 +327,10 @@ onMounted(() => { void loadRecentTournaments(); } }); + +onBeforeUnmount(() => { + clearOAuthPolling(); +});