feat(players): add Challonge v2.1 OAuth and import integration

This commit is contained in:
Pandipipas
2026-02-17 18:33:44 +01:00
parent 58c0c01e46
commit 9a5b64b2c0
5 changed files with 907 additions and 0 deletions
+352
View File
@@ -30,7 +30,20 @@ interface StartGGImportedPlayer extends Player {
id: string;
}
interface ChallongeTournament {
id: string;
name: string;
slug: string;
startAt: number | null;
endAt: number | null;
}
interface ChallongeImportedPlayer extends Player {
id: string;
}
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 STARTGG_TEMP_FALLBACK_DURATION_SECONDS = 12 * 60 * 60;
@@ -97,6 +110,7 @@ watch(
);
const startGGToken = ref(localStorage.getItem(STARTGG_TOKEN_STORAGE_KEY) ?? '');
const challongeToken = ref(localStorage.getItem(CHALLONGE_TOKEN_STORAGE_KEY) ?? '');
const recentTournaments = ref<StartGGTournament[]>([]);
const loadingTournaments = ref(false);
const tournamentsError = ref('');
@@ -111,10 +125,24 @@ const temporaryStartGGPlayers = ref<TemporaryStartGGPlayersMap>({});
let temporaryCleanupTimer: ReturnType<typeof setInterval> | null = null;
const oauthLoading = ref(false);
const challongeOauthLoading = ref(false);
const isManualTokenDialogOpen = ref(false);
const manualTokenDraft = ref('');
const oauthSessionId = ref('');
const challongeOauthSessionId = ref('');
let oauthPollingTimer: ReturnType<typeof setInterval> | null = null;
let challongeOauthPollingTimer: ReturnType<typeof setInterval> | null = null;
const challongeRecentTournaments = ref<ChallongeTournament[]>([]);
const challongeLoadingTournaments = ref(false);
const challongeTournamentsError = ref('');
const selectedChallongeTournamentSlug = ref('');
const challongeTournamentInput = ref('');
const challongePlayers = ref<ChallongeImportedPlayer[]>([]);
const selectedChallongePlayerIds = ref<string[]>([]);
const challongeImportDialogOpen = ref(false);
const loadingChallongeTournamentPlayers = ref(false);
const importingChallongeTournament = ref<ChallongeTournament | null>(null);
interface OAuthSessionResponse {
sessionId: string;
@@ -131,6 +159,10 @@ watch(startGGToken, (value) => {
localStorage.setItem(STARTGG_TOKEN_STORAGE_KEY, value);
});
watch(challongeToken, (value) => {
localStorage.setItem(CHALLONGE_TOKEN_STORAGE_KEY, value);
});
const persistTemporaryStartGGPlayers = () => {
localStorage.setItem(STARTGG_TEMP_PLAYERS_STORAGE_KEY, JSON.stringify(temporaryStartGGPlayers.value));
};
@@ -224,6 +256,13 @@ const clearOAuthPolling = () => {
}
};
const clearChallongeOAuthPolling = () => {
if (challongeOauthPollingTimer) {
clearInterval(challongeOauthPollingTimer);
challongeOauthPollingTimer = null;
}
};
const clearTemporaryCleanupTimer = () => {
if (temporaryCleanupTimer) {
clearInterval(temporaryCleanupTimer);
@@ -285,6 +324,40 @@ const checkOAuthStatus = async () => {
}
};
const checkChallongeOAuthStatus = async () => {
if (!challongeOauthSessionId.value) {
return;
}
try {
const status = await sendNodeCGMessage<OAuthStatusResponse>('challonge:getOAuthSessionStatus', {
sessionId: challongeOauthSessionId.value,
});
if (status.status === 'completed' && status.token) {
challongeToken.value = status.token;
challongeOauthLoading.value = false;
clearChallongeOAuthPolling();
challongeOauthSessionId.value = '';
challongeTournamentsError.value = '';
await loadChallongeRecentTournaments();
return;
}
if (status.status === 'error' || status.status === 'expired') {
challongeOauthLoading.value = false;
clearChallongeOAuthPolling();
challongeOauthSessionId.value = '';
challongeTournamentsError.value = status.error || 'Could not complete OAuth login with Challonge.';
}
} catch (error) {
challongeOauthLoading.value = false;
clearChallongeOAuthPolling();
challongeOauthSessionId.value = '';
challongeTournamentsError.value = error instanceof Error ? error.message : 'Could not verify OAuth status.';
}
};
const connectWithStartGGOAuth = async () => {
oauthLoading.value = true;
tournamentsError.value = '';
@@ -304,6 +377,25 @@ const connectWithStartGGOAuth = async () => {
}
};
const connectWithChallongeOAuth = async () => {
challongeOauthLoading.value = true;
challongeTournamentsError.value = '';
clearChallongeOAuthPolling();
try {
const session = await sendNodeCGMessage<OAuthSessionResponse>('challonge:createOAuthSession', {});
challongeOauthSessionId.value = session.sessionId;
window.open(session.authUrl, '_blank', 'noopener,noreferrer');
challongeOauthPollingTimer = setInterval(() => {
void checkChallongeOAuthStatus();
}, 1500);
} catch (error) {
challongeOauthLoading.value = false;
challongeTournamentsError.value = error instanceof Error ? error.message : 'Could not start OAuth with Challonge.';
}
};
const openManualTokenDialog = () => {
manualTokenDraft.value = startGGToken.value;
isManualTokenDialogOpen.value = true;
@@ -348,6 +440,119 @@ const loadRecentTournaments = async () => {
}
};
const challongeTournamentOptions = computed(() =>
challongeRecentTournaments.value.map((tournament) => ({
label: tournament.name,
value: tournament.slug,
caption: tournament.slug,
})),
);
const filteredChallongeTournamentOptions = ref(challongeTournamentOptions.value);
watch(challongeTournamentOptions, (value) => {
filteredChallongeTournamentOptions.value = value;
if (selectedChallongeTournamentSlug.value && !challongeRecentTournaments.value.some((item) => item.slug === selectedChallongeTournamentSlug.value)) {
selectedChallongeTournamentSlug.value = '';
challongeTournamentInput.value = '';
}
});
const selectedChallongeTournamentOption = computed(() =>
challongeRecentTournaments.value.find((item) => item.slug === selectedChallongeTournamentSlug.value) ?? null,
);
const canImportSelectedChallongeTournament = computed(() => Boolean(selectedChallongeTournamentOption.value));
const hasChallongeTokenConfigured = computed(() => Boolean(challongeToken.value.trim()));
const filterChallongeTournaments = (value: string, update: (callback: () => void) => void) => {
update(() => {
const needle = value.toLowerCase().trim();
if (!needle) {
filteredChallongeTournamentOptions.value = challongeTournamentOptions.value;
return;
}
filteredChallongeTournamentOptions.value = challongeTournamentOptions.value.filter((option) =>
option.label.toLowerCase().includes(needle) || option.caption.toLowerCase().includes(needle),
);
});
};
const loadChallongeRecentTournaments = async () => {
const token = challongeToken.value.trim();
if (!token) {
challongeTournamentsError.value = 'Add your Challonge token to load tournaments.';
challongeRecentTournaments.value = [];
return;
}
challongeTournamentsError.value = '';
challongeLoadingTournaments.value = true;
try {
const tournaments = await sendNodeCGMessage<ChallongeTournament[]>('challonge:fetchRecentTournaments', {
token,
});
challongeRecentTournaments.value = tournaments;
if (!tournaments.length) {
challongeTournamentsError.value = 'There are no recent tournaments for this account.';
}
} catch (error) {
challongeTournamentsError.value = error instanceof Error ? error.message : 'Could not load tournaments.';
challongeRecentTournaments.value = [];
} finally {
challongeLoadingTournaments.value = false;
}
};
const openChallongeImportDialog = async (tournament: ChallongeTournament) => {
importingChallongeTournament.value = tournament;
challongeImportDialogOpen.value = true;
loadingChallongeTournamentPlayers.value = true;
selectedChallongePlayerIds.value = [];
selectedChallongeTournamentSlug.value = tournament.slug;
challongeTournamentInput.value = tournament.name;
challongePlayers.value = [];
try {
const importedPlayers = await sendNodeCGMessage<ChallongeImportedPlayer[]>('challonge:fetchTournamentPlayers', {
token: challongeToken.value.trim(),
slug: tournament.slug,
});
challongePlayers.value = importedPlayers;
selectedChallongePlayerIds.value = importedPlayers.map((player) => player.id);
} catch (error) {
const message = error instanceof Error ? error.message : 'Could not load players';
window.alert(message);
challongeImportDialogOpen.value = false;
} finally {
loadingChallongeTournamentPlayers.value = false;
}
};
const openSelectedChallongeTournamentImportDialog = () => {
if (!selectedChallongeTournamentOption.value) {
return;
}
void openChallongeImportDialog(selectedChallongeTournamentOption.value);
};
const importSelectedChallongePlayers = () => {
const selectedPlayers = challongePlayers.value.filter((player) =>
selectedChallongePlayerIds.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,
});
});
challongeImportDialogOpen.value = false;
};
const openStartGGImportDialog = async (tournament: StartGGTournament) => {
importingTournament.value = tournament;
isImportDialogOpen.value = true;
@@ -490,10 +695,14 @@ onMounted(() => {
if (startGGToken.value.trim()) {
void loadRecentTournaments();
}
if (challongeToken.value.trim()) {
void loadChallongeRecentTournaments();
}
});
onBeforeUnmount(() => {
clearOAuthPolling();
clearChallongeOAuthPolling();
clearTemporaryCleanupTimer();
});
</script>
@@ -697,6 +906,104 @@ onBeforeUnmount(() => {
</div>
</QCard>
</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">
<span>Challonge</span>
</div>
<div class="text-caption q-mb-md">
Connect with OAuth or paste your personal token to load your Challonge tournaments and import participants.
</div>
<div class="row q-col-gutter-sm items-center">
<div class="col-auto">
<QBtn
v-if="!hasChallongeTokenConfigured"
color="primary"
icon="login"
label="Connect with Challonge"
:loading="challongeOauthLoading"
@click="connectWithChallongeOAuth"
/>
<QBtn
v-else
outline
color="positive"
icon="check_circle"
label="Connected"
/>
</div>
</div>
<div
v-if="challongeTournamentsError"
class="text-negative q-mt-sm"
>
{{ challongeTournamentsError }}
</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="challongeLoadingTournaments"
@click="loadChallongeRecentTournaments"
/>
<div class="col">
<QSelect
v-model="selectedChallongeTournamentSlug"
v-model:input-value="challongeTournamentInput"
:options="filteredChallongeTournamentOptions"
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="filterChallongeTournaments"
>
<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="canImportSelectedChallongeTournament"
class="col-auto"
>
<QBtn
color="primary"
unelevated
round
icon="person_add"
aria-label="Import players"
@click="openSelectedChallongeTournamentImportDialog"
>
<QTooltip>Import players</QTooltip>
</QBtn>
</div>
</div>
</QCard>
</div>
</div>
@@ -795,6 +1102,51 @@ onBeforeUnmount(() => {
</QCard>
</QDialog>
<QDialog v-model="challongeImportDialogOpen">
<QCard class="players-dialog">
<QCardSection>
<div class="text-h6">
Import from {{ importingChallongeTournament?.name || 'Challonge' }}
</div>
</QCardSection>
<QSeparator />
<QCardSection>
<div
v-if="loadingChallongeTournamentPlayers"
class="row items-center q-gutter-sm"
>
<QSpinner />
<span>Loading participants...</span>
</div>
<div v-else>
<QOptionGroup
v-model="selectedChallongePlayerIds"
type="checkbox"
:options="challongePlayers.map((player) => ({
label: `${player.gamertag}${player.team ? ` (${player.team})` : ''}`,
value: player.id,
}))"
/>
</div>
</QCardSection>
<QSeparator />
<QCardActions align="right">
<QBtn
flat
label="Cancel"
color="secondary"
@click="challongeImportDialogOpen = false"
/>
<QBtn
color="primary"
label="Import selected"
:disable="!selectedChallongePlayerIds.length"
@click="importSelectedChallongePlayers"
/>
</QCardActions>
</QCard>
</QDialog>
<QDialog v-model="isDialogOpen">
<QCard class="players-dialog">
<QCardSection>