mirror of
https://github.com/Pandipipas/scoreko-dev.git
synced 2026-06-06 03:32:06 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user