diff --git a/configschema.json b/configschema.json index fd5bfda..9488047 100644 --- a/configschema.json +++ b/configschema.json @@ -5,6 +5,23 @@ "properties": { "exampleProperty": { "type": "string" + }, + "startggClientId": { + "type": "string", + "default": "", + "description": "Client ID de tu OAuth app de start.gg" + }, + "startggClientSecret": { + "type": "string", + "default": "", + "description": "Client Secret de tu OAuth app de start.gg" + }, + "startggOAuthPort": { + "type": "integer", + "default": 34920, + "minimum": 1, + "maximum": 65535, + "description": "Puerto local para callback OAuth" } }, "required": [ diff --git a/src/dashboard/scoreko-dev/views/Players.vue b/src/dashboard/scoreko-dev/views/Players.vue index 668b466..deec974 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, 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; + const playersStore = usePlayersStore(); const rows = computed(() => playersStore.rows); @@ -73,6 +96,323 @@ 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 importingTournament = ref(null); +const startGGPlayers = ref([]); +const selectedStartGGPlayerIds = ref([]); +const selectedTournamentSlug = ref(''); +const tournamentInput = ref(''); +const temporaryStartGGPlayers = ref({}); +let temporaryCleanupTimer: ReturnType | null = null; + +const oauthLoading = ref(false); +const isManualTokenDialogOpen = ref(false); +const manualTokenDraft = ref(''); +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); +}); + +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).forEach(([playerId, value]) => { + if (!playerId || typeof value !== 'object' || value === null) { + return; + } + const candidate = value as Record; + 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 = (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 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('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('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('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('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(); +});