Merge pull request #83 from Pandipipas/integrate-with-startgg-for-tournament-syncing

Add start.gg tournament sync and player import workflow
This commit is contained in:
Pandipipas
2026-02-17 18:23:44 +01:00
committed by GitHub
5 changed files with 1240 additions and 54 deletions
+670 -54
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, onBeforeUnmount, 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,29 @@ interface PlayerRow extends Player {
id: string;
}
interface StartGGTournament {
id: number;
name: string;
slug: string;
startAt: number | null;
endAt: number | null;
}
interface StartGGImportedPlayer extends Player {
id: string;
}
const STARTGG_TOKEN_STORAGE_KEY = 'scoreko-dev.startgg-token';
const STARTGG_TEMP_PLAYERS_STORAGE_KEY = 'scoreko-dev.startgg-temp-players';
const STARTGG_TEMP_FALLBACK_DURATION_SECONDS = 12 * 60 * 60;
interface TemporaryStartGGPlayerMeta {
expiresAt: number;
tournamentSlug: string;
}
type TemporaryStartGGPlayersMap = Record<string, TemporaryStartGGPlayerMeta>;
const playersStore = usePlayersStore();
const rows = computed<PlayerRow[]>(() => playersStore.rows);
@@ -73,6 +96,323 @@ 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 importingTournament = ref<StartGGTournament | null>(null);
const startGGPlayers = ref<StartGGImportedPlayer[]>([]);
const selectedStartGGPlayerIds = ref<string[]>([]);
const selectedTournamentSlug = ref('');
const tournamentInput = ref('');
const temporaryStartGGPlayers = ref<TemporaryStartGGPlayersMap>({});
let temporaryCleanupTimer: ReturnType<typeof setInterval> | null = null;
const oauthLoading = ref(false);
const isManualTokenDialogOpen = ref(false);
const manualTokenDraft = ref('');
const oauthSessionId = ref('');
let oauthPollingTimer: ReturnType<typeof setInterval> | 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);
});
const persistTemporaryStartGGPlayers = () => {
localStorage.setItem(STARTGG_TEMP_PLAYERS_STORAGE_KEY, JSON.stringify(temporaryStartGGPlayers.value));
};
const loadTemporaryStartGGPlayers = (): TemporaryStartGGPlayersMap => {
try {
const raw = localStorage.getItem(STARTGG_TEMP_PLAYERS_STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed !== 'object' || parsed === null) {
return {};
}
const result: TemporaryStartGGPlayersMap = {};
Object.entries(parsed as Record<string, unknown>).forEach(([playerId, value]) => {
if (!playerId || typeof value !== 'object' || value === null) {
return;
}
const candidate = value as Record<string, unknown>;
const expiresAt = Number(candidate.expiresAt);
const tournamentSlug = String(candidate.tournamentSlug || '').trim();
if (!Number.isFinite(expiresAt) || expiresAt <= 0 || !tournamentSlug) {
return;
}
result[playerId] = {
expiresAt,
tournamentSlug,
};
});
return result;
} catch {
return {};
}
};
const tournamentOptions = computed(() =>
recentTournaments.value.map((tournament) => ({
label: tournament.name,
value: tournament.slug,
caption: tournament.slug,
})),
);
const filteredTournamentOptions = ref(tournamentOptions.value);
watch(tournamentOptions, (value) => {
filteredTournamentOptions.value = value;
if (selectedTournamentSlug.value && !recentTournaments.value.some((item) => item.slug === selectedTournamentSlug.value)) {
selectedTournamentSlug.value = '';
tournamentInput.value = '';
}
});
const selectedTournamentOption = computed(() =>
recentTournaments.value.find((item) => item.slug === selectedTournamentSlug.value) ?? null,
);
const canImportSelectedTournament = computed(() => Boolean(selectedTournamentOption.value));
const hasStartGGTokenConfigured = computed(() => Boolean(startGGToken.value.trim()));
const filterTournaments = (value: string, update: (callback: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
if (!needle) {
filteredTournamentOptions.value = tournamentOptions.value;
return;
}
filteredTournamentOptions.value = tournamentOptions.value.filter((option) =>
option.label.toLowerCase().includes(needle) || option.caption.toLowerCase().includes(needle),
);
});
};
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 clearOAuthPolling = () => {
if (oauthPollingTimer) {
clearInterval(oauthPollingTimer);
oauthPollingTimer = null;
}
};
const clearTemporaryCleanupTimer = () => {
if (temporaryCleanupTimer) {
clearInterval(temporaryCleanupTimer);
temporaryCleanupTimer = null;
}
};
const cleanupExpiredTemporaryPlayers = () => {
const now = Math.floor(Date.now() / 1000);
const expiredIds = Object.entries(temporaryStartGGPlayers.value)
.filter(([, meta]) => meta.expiresAt <= now)
.map(([playerId]) => playerId);
if (!expiredIds.length) {
return;
}
const nextMeta = { ...temporaryStartGGPlayers.value };
expiredIds.forEach((playerId) => {
playersStore.removePlayer(playerId);
delete nextMeta[playerId];
});
temporaryStartGGPlayers.value = nextMeta;
persistTemporaryStartGGPlayers();
};
const checkOAuthStatus = async () => {
if (!oauthSessionId.value) {
return;
}
try {
const status = await sendNodeCGMessage<OAuthStatusResponse>('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 || 'Could not complete OAuth login with start.gg.';
}
} catch (error) {
oauthLoading.value = false;
clearOAuthPolling();
oauthSessionId.value = '';
tournamentsError.value = error instanceof Error ? error.message : 'Could not verify OAuth status.';
}
};
const connectWithStartGGOAuth = async () => {
oauthLoading.value = true;
tournamentsError.value = '';
clearOAuthPolling();
try {
const session = await sendNodeCGMessage<OAuthSessionResponse>('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 : 'Could not start OAuth with start.gg.';
}
};
const openManualTokenDialog = () => {
manualTokenDraft.value = startGGToken.value;
isManualTokenDialogOpen.value = true;
};
const saveManualToken = () => {
startGGToken.value = manualTokenDraft.value.trim();
if (!startGGToken.value) {
recentTournaments.value = [];
selectedTournamentSlug.value = '';
tournamentInput.value = '';
tournamentsError.value = '';
}
isManualTokenDialogOpen.value = false;
};
const loadRecentTournaments = async () => {
const token = startGGToken.value.trim();
if (!token) {
tournamentsError.value = 'Add your start.gg token to load tournaments.';
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 = 'There are no recent tournaments for this account.';
}
} catch (error) {
tournamentsError.value = error instanceof Error ? error.message : 'Could not load tournaments.';
recentTournaments.value = [];
} finally {
loadingTournaments.value = false;
}
};
const openStartGGImportDialog = async (tournament: StartGGTournament) => {
importingTournament.value = tournament;
isImportDialogOpen.value = true;
loadingTournamentPlayers.value = true;
selectedStartGGPlayerIds.value = [];
selectedTournamentSlug.value = tournament.slug;
tournamentInput.value = tournament.name;
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 : 'Could not load players';
window.alert(message);
isImportDialogOpen.value = false;
} finally {
loadingTournamentPlayers.value = false;
}
};
const openSelectedTournamentImportDialog = () => {
if (!selectedTournamentOption.value) {
return;
}
void openStartGGImportDialog(selectedTournamentOption.value);
};
const importSelectedStartGGPlayers = () => {
const selectedPlayers = startGGPlayers.value.filter((player) =>
selectedStartGGPlayerIds.value.includes(player.id),
);
const nextMeta = { ...temporaryStartGGPlayers.value };
const tournament = importingTournament.value;
const fallbackEndAt = (tournament?.startAt ?? Math.floor(Date.now() / 1000)) + STARTGG_TEMP_FALLBACK_DURATION_SECONDS;
const expiresAt = tournament?.endAt ?? fallbackEndAt;
selectedPlayers.forEach((player) => {
playersStore.upsertPlayer(player.id, {
gamertag: player.gamertag,
name: player.name,
team: player.team,
country: player.country,
twitter: player.twitter,
});
if (tournament) {
nextMeta[player.id] = {
expiresAt,
tournamentSlug: tournament.slug,
};
}
});
temporaryStartGGPlayers.value = nextMeta;
persistTemporaryStartGGPlayers();
isImportDialogOpen.value = false;
};
const generateId = () => {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID();
@@ -141,6 +481,21 @@ const handleImport = async (event: Event) => {
}
}
};
onMounted(() => {
temporaryStartGGPlayers.value = loadTemporaryStartGGPlayers();
cleanupExpiredTemporaryPlayers();
temporaryCleanupTimer = setInterval(cleanupExpiredTemporaryPlayers, 60 * 1000);
if (startGGToken.value.trim()) {
void loadRecentTournaments();
}
});
onBeforeUnmount(() => {
clearOAuthPolling();
clearTemporaryCleanupTimer();
});
</script>
<template>
@@ -159,68 +514,286 @@ const handleImport = async (event: Event) => {
/>
</div>
<div class="row items-center q-gutter-sm q-mb-md">
<QInput
v-model="filter"
dense
placeholder="Search..."
class="players-search players-underlined-field"
clearable
>
<template #prepend>
<QIcon name="search" />
</template>
</QInput>
<QBtn
color="secondary"
outline
icon="file_upload"
label="Import"
@click="triggerImport"
/>
<QBtn
color="secondary"
outline
icon="file_download"
label="Export"
@click="exportPlayers"
/>
<input
ref="fileInput"
type="file"
class="visually-hidden"
accept="application/json"
@change="handleImport"
>
<div class="players-content row q-col-gutter-md">
<div class="col-12">
<div class="row items-center q-gutter-sm q-mb-md">
<QInput
v-model="filter"
dense
placeholder="Search..."
class="players-search players-underlined-field"
clearable
>
<template #prepend>
<QIcon name="search" />
</template>
</QInput>
<QBtn
color="secondary"
outline
icon="file_upload"
label="Import"
@click="triggerImport"
/>
<QBtn
color="secondary"
outline
icon="file_download"
label="Export"
@click="exportPlayers"
/>
<input
ref="fileInput"
type="file"
class="visually-hidden"
accept="application/json"
@change="handleImport"
>
</div>
</div>
<div class="col-12 col-lg-8 players-main-column">
<QTable
flat
bordered
row-key="id"
:rows="rows"
:columns="columns"
:filter="filter"
:rows-per-page-options="[10, 20, 50]"
>
<template #body-cell-actions="{ row }">
<QTd align="right">
<QBtn
size="sm"
flat
icon="edit"
@click="openEditDialog(row)"
/>
<QBtn
size="sm"
flat
color="negative"
icon="delete"
@click="deletePlayer(row)"
/>
</QTd>
</template>
</QTable>
</div>
<div class="col-12 col-lg-4 players-startgg-column">
<QCard
flat
bordered
class="q-pa-md"
>
<div class="text-h6 q-mb-sm startgg-heading">
<svg
class="startgg-heading__icon"
viewBox="0 0 24 24"
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" />
</svg>
<span>StartGG</span>
</div>
<div class="text-caption q-mb-md">
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.
</div>
<div class="row q-col-gutter-sm items-center">
<div class="col-auto">
<QBtn
v-if="!hasStartGGTokenConfigured"
color="primary"
icon="login"
label="Connect with start.gg"
:loading="oauthLoading"
@click="connectWithStartGGOAuth"
/>
<QBtn
v-else
outline
color="positive"
icon="check_circle"
label="Connected"
class="startgg-connected-btn"
@click="openManualTokenDialog"
/>
</div>
<div class="col-auto">
<QBtn
outline
color="white"
icon="vpn_key"
label="Use personal API"
@click="openManualTokenDialog"
/>
</div>
</div>
<div
v-if="tournamentsError"
class="text-negative q-mt-sm"
>
{{ tournamentsError }}
</div>
<div class="row items-center q-mt-md startgg-tournament-row">
<QBtn
flat
round
dense
text-color="white"
icon="sync"
class="startgg-refresh-btn"
:loading="loadingTournaments"
@click="loadRecentTournaments"
/>
<div class="col">
<QSelect
v-model="selectedTournamentSlug"
v-model:input-value="tournamentInput"
:options="filteredTournamentOptions"
option-value="value"
option-label="label"
emit-value
map-options
use-input
hide-selected
fill-input
input-debounce="0"
clearable
dense
label="Tournament"
class="players-underlined-field"
@filter="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="canImportSelectedTournament"
class="col-auto"
>
<QBtn
color="primary"
unelevated
round
icon="person_add"
aria-label="Import players"
@click="openSelectedTournamentImportDialog"
>
<QTooltip>Import players</QTooltip>
</QBtn>
</div>
</div>
</QCard>
</div>
</div>
<QTable
flat
bordered
row-key="id"
:rows="rows"
:columns="columns"
:filter="filter"
:rows-per-page-options="[10, 20, 50]"
>
<template #body-cell-actions="{ row }">
<QTd align="right">
<QDialog v-model="isManualTokenDialogOpen">
<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="manualTokenDraft"
label="Paste your personal token"
dense
outlined
type="password"
/>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn
size="sm"
flat
icon="edit"
@click="openEditDialog(row)"
label="Cancel"
color="secondary"
@click="isManualTokenDialogOpen = false"
/>
<QBtn
size="sm"
flat
color="negative"
icon="delete"
@click="deletePlayer(row)"
label="Delete token"
@click="manualTokenDraft = ''; saveManualToken()"
/>
</QTd>
</template>
</QTable>
<QBtn
color="primary"
label="Save token"
@click="saveManualToken"
/>
</QCardActions>
</QCard>
</QDialog>
<QDialog v-model="isImportDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">
Import from {{ importingTournament?.name || 'start.gg' }}
</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div
v-if="loadingTournamentPlayers"
class="row items-center q-gutter-sm"
>
<QSpinner />
<span>Loading participants...</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="Cancel"
color="secondary"
@click="isImportDialogOpen = false"
/>
<QBtn
color="primary"
label="Import selected"
:disable="!selectedStartGGPlayerIds.length"
@click="importSelectedStartGGPlayers"
/>
</QCardActions>
</QCard>
</QDialog>
<QDialog v-model="isDialogOpen">
<QCard class="players-dialog">
@@ -319,11 +892,50 @@ const handleImport = async (event: Event) => {
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);
}
.startgg-heading {
display: inline-flex;
align-items: center;
gap: 8px;
}
.startgg-heading__icon {
width: 20px;
height: 20px;
fill: #2e75ba;
}
.startgg-tournament-row {
gap: 4px;
}
.startgg-refresh-btn:hover {
background: rgba(255, 255, 255, 0.12);
}
.startgg-connected-btn {
font-weight: 600;
}
.players-underlined-field :deep(.q-field__control) {
min-height: 28px;
padding: 0;
@@ -337,6 +949,10 @@ const handleImport = async (event: Event) => {
border-bottom: 1px solid rgba(255, 255, 255, 0.34);
}
.manual-token-steps {
line-height: 1.5;
}
.visually-hidden {
position: absolute;
width: 1px;