diff --git a/src/dashboard/scoreko-dev/composables/useIntegration.ts b/src/dashboard/scoreko-dev/composables/useIntegration.ts new file mode 100644 index 0000000..2c3118b --- /dev/null +++ b/src/dashboard/scoreko-dev/composables/useIntegration.ts @@ -0,0 +1,444 @@ +import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; + +// ─── Tipos ───────────────────────────────────────────────────────────────────── + +export interface IntegrationTournament { + id: string | number; + name: string; + slug: string; + startAt: number | null; + endAt: number | null; +} + +export interface IntegrationPlayer { + id: string; + gamertag: string; + name: string; + team: string; + country: string; + twitter: string; +} + +export interface TemporaryPlayerMeta { + expiresAt: number; + tournamentSlug: string; +} + +export type TemporaryPlayersMap = Record; + +interface TournamentOption { + label: string; + value: string; + caption: string; +} + +interface OAuthSessionResponse { + sessionId: string; + authUrl: string; +} + +interface OAuthStatusResponse { + status: 'pending' | 'completed' | 'error' | 'expired'; + token?: string; + error?: string; +} + +export interface PlayersStore { + upsertPlayer: (id: string, data: Omit) => void; + removePlayer: (id: string) => void; +} + +export interface UseIntegrationOptions { + /** Prefijo de los mensajes NodeCG, p.ej. 'startgg' | 'challonge' */ + messagePrefix: string; + /** Nombre legible del proveedor para mensajes de error */ + providerLabel: string; + /** Clave de localStorage para el token */ + tokenStorageKey: string; + /** Clave de localStorage para los jugadores temporales */ + tempPlayersStorageKey: string; + /** Segundos que duran los jugadores temporales si el torneo no tiene endAt */ + tempFallbackDurationSeconds: number; + /** Mensaje de error personalizado cuando la API devuelve 401 */ + on401Message?: string; + /** Store de jugadores */ + playersStore: PlayersStore; +} + +// ─── Utilidad para mensajes NodeCG ───────────────────────────────────────────── + +const sendNodeCGMessage = (messageName: string, payload: unknown): Promise => + new Promise((resolve, reject) => { + nodecg.sendMessage(messageName, payload, (error: unknown, response: unknown) => { + if (error) { + reject(new Error(String(error))); + return; + } + resolve(response as T); + }); + }); + +// ─── Composable ──────────────────────────────────────────────────────────────── + +export function useIntegration(options: UseIntegrationOptions) { + const { + messagePrefix, + providerLabel, + tokenStorageKey, + tempPlayersStorageKey, + tempFallbackDurationSeconds, + on401Message, + playersStore, + } = options; + + // ── Token ─────────────────────────────────────────────────────────────────── + const token = ref(localStorage.getItem(tokenStorageKey) ?? ''); + const hasValidatedToken = ref(false); + + watch(token, (value) => { + localStorage.setItem(tokenStorageKey, value); + hasValidatedToken.value = false; + if (!value.trim()) { + recentTournaments.value = []; + selectedTournamentSlug.value = ''; + tournamentInput.value = ''; + tournamentsError.value = ''; + } + }); + + // ── Lista de torneos ──────────────────────────────────────────────────────── + const recentTournaments = ref([]); + const loadingTournaments = ref(false); + const tournamentsError = ref(''); + const selectedTournamentSlug = ref(''); + const tournamentInput = ref(''); + + const tournamentOptions = computed(() => + recentTournaments.value.map((t) => ({ + label: t.name, + value: t.slug, + caption: t.slug, + })), + ); + + const filteredTournamentOptions = ref(tournamentOptions.value); + + watch(tournamentOptions, (value) => { + filteredTournamentOptions.value = value; + if ( + selectedTournamentSlug.value && + !recentTournaments.value.some((t) => t.slug === selectedTournamentSlug.value) + ) { + selectedTournamentSlug.value = ''; + tournamentInput.value = ''; + } + }); + + const filterTournaments = (value: string, update: (cb: () => void) => void) => { + update(() => { + const needle = value.toLowerCase().trim(); + filteredTournamentOptions.value = needle + ? tournamentOptions.value.filter( + (o) => + o.label.toLowerCase().includes(needle) || + o.caption.toLowerCase().includes(needle), + ) + : tournamentOptions.value; + }); + }; + + const selectedTournamentOption = computed( + () => recentTournaments.value.find((t) => t.slug === selectedTournamentSlug.value) ?? null, + ); + + const canImportSelectedTournament = computed(() => Boolean(selectedTournamentOption.value)); + const hasTokenConfigured = computed(() => Boolean(token.value.trim())); + + const loadRecentTournaments = async () => { + const currentToken = token.value.trim(); + if (!currentToken) { + tournamentsError.value = `Add your ${providerLabel} token to load tournaments.`; + recentTournaments.value = []; + return; + } + + tournamentsError.value = ''; + loadingTournaments.value = true; + try { + const tournaments = await sendNodeCGMessage( + `${messagePrefix}:fetchRecentTournaments`, + { token: currentToken }, + ); + hasValidatedToken.value = true; + recentTournaments.value = tournaments; + if (!tournaments.length) { + tournamentsError.value = 'There are no recent tournaments for this account.'; + } + } catch (error) { + hasValidatedToken.value = false; + const message = error instanceof Error ? error.message : 'Could not load tournaments.'; + tournamentsError.value = + on401Message && message.includes('401') ? on401Message : message; + recentTournaments.value = []; + } finally { + loadingTournaments.value = false; + } + }; + + // ── Importación de jugadores ──────────────────────────────────────────────── + const players = ref([]); + const selectedPlayerIds = ref([]); + const importDialogOpen = ref(false); + const importDialogError = ref(''); + const loadingPlayers = ref(false); + const importingTournament = ref(null); + + const openImportDialog = async (tournament: IntegrationTournament): Promise => { + importingTournament.value = tournament; + importDialogOpen.value = true; + importDialogError.value = ''; + loadingPlayers.value = true; + selectedPlayerIds.value = []; + selectedTournamentSlug.value = tournament.slug; + tournamentInput.value = tournament.name; + players.value = []; + + try { + const importedPlayers = await sendNodeCGMessage( + `${messagePrefix}:fetchTournamentPlayers`, + { token: token.value.trim(), slug: tournament.slug }, + ); + players.value = importedPlayers; + selectedPlayerIds.value = importedPlayers.map((p) => p.id); + } catch (error) { + importDialogError.value = + error instanceof Error ? error.message : 'Could not load players'; + importDialogOpen.value = false; + } finally { + loadingPlayers.value = false; + } + }; + + const openSelectedTournamentImportDialog = () => { + if (selectedTournamentOption.value) { + void openImportDialog(selectedTournamentOption.value); + } + }; + + const toggleAllPlayers = () => { + selectedPlayerIds.value = + selectedPlayerIds.value.length === players.value.length + ? [] + : players.value.map((p) => p.id); + }; + + const importSelectedPlayers = () => { + const selected = players.value.filter((p) => selectedPlayerIds.value.includes(p.id)); + const tournament = importingTournament.value; + const fallbackEndAt = + (tournament?.startAt ?? Math.floor(Date.now() / 1000)) + tempFallbackDurationSeconds; + const expiresAt = tournament?.endAt ?? fallbackEndAt; + const nextMeta = { ...temporaryPlayers.value }; + + for (const player of selected) { + 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 }; + } + } + + temporaryPlayers.value = nextMeta; + persistTemporaryPlayers(); + importDialogOpen.value = false; + }; + + // ── Jugadores temporales ──────────────────────────────────────────────────── + const loadTemporaryPlayers = (): TemporaryPlayersMap => { + try { + const raw = localStorage.getItem(tempPlayersStorageKey); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null) return {}; + + const result: TemporaryPlayersMap = {}; + 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 temporaryPlayers = ref({}); + + const persistTemporaryPlayers = () => { + localStorage.setItem(tempPlayersStorageKey, JSON.stringify(temporaryPlayers.value)); + }; + + /** + * Elimina del store y del mapa los jugadores temporales cuyo expiresAt + * ha pasado. Se llama periódicamente en onMounted. + */ + const cleanupExpiredTemporaryPlayers = () => { + const now = Math.floor(Date.now() / 1000); + const expiredIds = Object.entries(temporaryPlayers.value) + .filter(([, meta]) => meta.expiresAt <= now) + .map(([id]) => id); + + if (!expiredIds.length) return; + + const nextMeta = { ...temporaryPlayers.value }; + for (const id of expiredIds) { + playersStore.removePlayer(id); + delete nextMeta[id]; + } + temporaryPlayers.value = nextMeta; + persistTemporaryPlayers(); + }; + + // ── OAuth ─────────────────────────────────────────────────────────────────── + const oauthLoading = ref(false); + const oauthSessionId = ref(''); + let oauthPollingTimer: ReturnType | null = null; + + const stopPolling = () => { + if (oauthPollingTimer) { + clearInterval(oauthPollingTimer); + oauthPollingTimer = null; + } + }; + + const checkOAuthStatus = async () => { + if (!oauthSessionId.value) return; + + try { + const status = await sendNodeCGMessage( + `${messagePrefix}:getOAuthSessionStatus`, + { sessionId: oauthSessionId.value }, + ); + + if (status.status === 'completed' && status.token) { + token.value = status.token; + oauthLoading.value = false; + stopPolling(); + oauthSessionId.value = ''; + tournamentsError.value = ''; + await loadRecentTournaments(); + return; + } + + if (status.status === 'error' || status.status === 'expired') { + oauthLoading.value = false; + stopPolling(); + oauthSessionId.value = ''; + tournamentsError.value = + status.error ?? `Could not complete OAuth login with ${providerLabel}.`; + } + } catch (error) { + oauthLoading.value = false; + stopPolling(); + oauthSessionId.value = ''; + tournamentsError.value = + error instanceof Error ? error.message : 'Could not verify OAuth status.'; + } + }; + + const connectWithOAuth = async () => { + oauthLoading.value = true; + tournamentsError.value = ''; + stopPolling(); + + try { + const session = await sendNodeCGMessage( + `${messagePrefix}: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 ${providerLabel}.`; + } + }; + + // ── Ciclo de vida ─────────────────────────────────────────────────────────── + let cleanupTimer: ReturnType | null = null; + + onMounted(() => { + temporaryPlayers.value = loadTemporaryPlayers(); + cleanupExpiredTemporaryPlayers(); + cleanupTimer = setInterval(cleanupExpiredTemporaryPlayers, 60 * 1000); + + if (token.value.trim()) { + void loadRecentTournaments(); + } + }); + + onBeforeUnmount(() => { + stopPolling(); + if (cleanupTimer) { + clearInterval(cleanupTimer); + cleanupTimer = null; + } + }); + + // ── Retorno como reactive para auto-unwrap en templates ───────────────────── + return reactive({ + // Token + token, + hasTokenConfigured, + hasValidatedToken, + + // Torneos + recentTournaments, + loadingTournaments, + tournamentsError, + selectedTournamentSlug, + tournamentInput, + tournamentOptions, + filteredTournamentOptions, + selectedTournamentOption, + canImportSelectedTournament, + filterTournaments, + loadRecentTournaments, + + // Importación + players, + selectedPlayerIds, + importDialogOpen, + importDialogError, + loadingPlayers, + importingTournament, + openImportDialog, + openSelectedTournamentImportDialog, + importSelectedPlayers, + toggleAllPlayers, + + // Jugadores temporales + temporaryPlayers, + + // OAuth + oauthLoading, + connectWithOAuth, + }); +} + +export type IntegrationHandle = ReturnType; diff --git a/src/dashboard/scoreko-dev/views/Players.vue b/src/dashboard/scoreko-dev/views/Players.vue index 39b668d..bf46a61 100644 --- a/src/dashboard/scoreko-dev/views/Players.vue +++ b/src/dashboard/scoreko-dev/views/Players.vue @@ -1,9 +1,10 @@