🎨 Improve code readability by reformatting and updating function definitions and comments

This commit is contained in:
2026-01-28 18:18:55 +00:00
parent 1944b43af8
commit a24f766e37
154 changed files with 7261 additions and 117 deletions

View File

@@ -1,71 +1,51 @@
<template>
<div class="container">
<ProfessorPokeball size="150px" color="#F44336" :animate="true" />
<h1>Pokedex Online</h1>
<p class="subtitle">Your Digital Pokédex Companion</p>
<p class="description">
A modern web application for housing different apps that make a professors
life easier. Built with for Pokémon Professors everywhere.
</p>
<div class="status">
<strong>Status:</strong> In Development<br />
Check back soon for updates!
</div>
<div id="app">
<Transition name="view-transition" mode="out-in">
<router-view />
</Transition>
</div>
</template>
<script setup>
import ProfessorPokeball from './components/shared/ProfessorPokeball.vue';
// App now acts as the router container with transitions
</script>
<style scoped>
.container {
background: white;
border-radius: 20px;
padding: 60px 40px;
max-width: 600px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
text-align: center;
<style>
/* Global styles and transitions */
html,
body {
margin: 0;
padding: 0;
}
h1 {
color: #333;
margin-bottom: 20px;
font-size: 2.5em;
/* Transition animations for view changes */
.view-transition-enter-active {
animation: slideIn 0.5s ease-out;
}
.subtitle {
color: #667eea;
font-size: 1.2em;
margin-bottom: 30px;
.view-transition-leave-active {
animation: dropOut 0.4s ease-in;
}
.description {
color: #666;
line-height: 1.6;
margin-bottom: 30px;
}
.status {
background: #f0f0f0;
padding: 15px;
border-radius: 10px;
color: #666;
font-size: 0.9em;
}
.status strong {
color: #667eea;
}
@media (max-width: 600px) {
.container {
padding: 40px 20px;
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(100px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
h1 {
font-size: 2em;
@keyframes dropOut {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(50px) scale(0.95);
}
}
</style>

View File

@@ -0,0 +1,440 @@
<template>
<div class="guide-overlay" @click.self="$emit('close')">
<div class="guide-modal">
<button class="close-btn" @click="$emit('close')"></button>
<h1>Getting Your Challonge API Key</h1>
<div class="steps">
<div class="step">
<div class="step-header">
<div class="step-number">1</div>
<h2>Log Into Challonge</h2>
</div>
<div class="step-content">
<p>
Go to
<a href="https://challonge.com/login" target="_blank"
>Challonge.com</a
>
and log in with your account credentials.
</p>
</div>
</div>
<div class="step">
<div class="step-header">
<div class="step-number">2</div>
<h2>Go to Developer Settings</h2>
</div>
<div class="step-content">
<p>
Once logged in, visit your
<a
href="https://challonge.com/settings/developer"
target="_blank"
>
Developer Settings page
</a>
</p>
<p>
You'll see information about your account and any existing
applications.
</p>
</div>
</div>
<div class="step">
<div class="step-header">
<div class="step-number">3</div>
<h2>Click "Manage" Button</h2>
</div>
<div class="step-content">
<p>
On the Developer Settings page, look for a button labeled
<strong>"Manage.challonge.com"</strong> or similar.
</p>
<p>Click this button to go to the app management portal.</p>
</div>
</div>
<div class="step">
<div class="step-header">
<div class="step-number">4</div>
<h2>Create a New Application</h2>
</div>
<div class="step-content">
<p>
On the app management page, look for a button to create a new
application.
</p>
<p>
Click it to create a new app. You'll be taken to the app
creation/edit screen.
</p>
</div>
</div>
<div class="step">
<div class="step-header">
<div class="step-number">5</div>
<h2>Fill in App Details</h2>
</div>
<div class="step-content">
<p>On the app screen, you'll see several fields:</p>
<ul class="field-list">
<li>
<strong>Name:</strong> Give your app a name (e.g., "Tournament
Manager", "Pokedex Online")
</li>
<li>
<strong>Description:</strong> Optional description of what the
app does
</li>
<li>
<strong>Reference link:</strong> Use
<code>https://challonge.com</code> or your own website URL
</li>
</ul>
</div>
</div>
<div class="step">
<div class="step-header">
<div class="step-number">6</div>
<h2>Copy Your API Key</h2>
</div>
<div class="step-content">
<p>
After creating the app, you'll see your
<strong>API Key</strong> displayed on the app screen.
</p>
<p class="important">
<strong>Important:</strong> Copy this key carefully. It usually
appears as a long string of characters.
</p>
<p>This is the key you'll store in the API Key Manager.</p>
</div>
</div>
<div class="step">
<div class="step-header">
<div class="step-number">7</div>
<h2>Store in API Key Manager</h2>
</div>
<div class="step-content">
<p>
Return to this app and go to the
<strong>API Key Manager</strong> (linked below).
</p>
<p>Paste your API key there and click "Save Key".</p>
<p class="success">
✅ Your API key is now stored and ready to use!
</p>
</div>
</div>
</div>
<div class="guide-footer">
<div class="security-note">
<h3>🔒 Security Reminder</h3>
<ul>
<li>Never share your API key with anyone</li>
<li>Don't commit it to version control or public repositories</li>
<li>
If you accidentally expose your key, regenerate it in your
Challonge app settings
</li>
<li>
Your key is stored securely in your browser (not sent to any
server)
</li>
</ul>
</div>
<button class="btn btn-primary btn-large" @click="goToKeyManager">
Go to API Key Manager
</button>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
const router = useRouter();
defineEmits(['close']);
function goToKeyManager() {
router.push('/api-key-manager');
}
</script>
<style scoped>
.guide-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
overflow-y: auto;
}
.guide-modal {
background: white;
border-radius: 12px;
padding: 3rem;
max-width: 800px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
position: relative;
max-height: 90vh;
overflow-y: auto;
}
.close-btn {
position: absolute;
top: 1.5rem;
right: 1.5rem;
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #999;
transition: color 0.3s ease;
}
.close-btn:hover {
color: #333;
}
h1 {
color: #333;
margin-bottom: 2rem;
font-size: 2rem;
text-align: center;
}
.steps {
display: flex;
flex-direction: column;
gap: 2rem;
margin-bottom: 2rem;
}
.step {
background: #f8f9fa;
border-left: 4px solid #667eea;
padding: 1.5rem;
border-radius: 8px;
}
.step-header {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.step-number {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 700;
font-size: 1.1rem;
flex-shrink: 0;
}
.step-header h2 {
color: #333;
font-size: 1.3rem;
margin: 0;
flex: 1;
}
.step-content {
margin-left: calc(40px + 1rem);
}
.step-content p {
color: #666;
margin: 0.75rem 0;
line-height: 1.6;
}
.step-content p:first-child {
margin-top: 0;
}
.step-content p:last-child {
margin-bottom: 0;
}
.field-list {
list-style: none;
padding: 0;
margin: 1rem 0;
}
.field-list li {
padding: 0.75rem;
margin: 0.5rem 0;
background: white;
border-radius: 6px;
border-left: 3px solid #667eea;
}
.field-list strong {
color: #333;
display: inline-block;
min-width: 100px;
}
code {
background: #e9ecef;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
.important {
background: #fff3cd;
padding: 1rem;
border-radius: 6px;
border-left: 4px solid #ffc107;
color: #856404;
}
.success {
background: #d4edda;
padding: 1rem;
border-radius: 6px;
border-left: 4px solid #28a745;
color: #155724;
margin: 1rem 0 0 0;
}
.guide-footer {
border-top: 2px solid #e9ecef;
padding-top: 2rem;
}
.security-note {
background: #f0f4ff;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
border-left: 4px solid #667eea;
}
.security-note h3 {
color: #667eea;
margin-top: 0;
font-size: 1.1rem;
}
.security-note ul {
margin: 0;
padding-left: 1.5rem;
list-style: none;
}
.security-note li {
color: #495057;
margin: 0.5rem 0;
padding-left: 1.5rem;
position: relative;
}
.security-note li:before {
content: '✓';
position: absolute;
left: 0;
color: #667eea;
font-weight: 700;
}
.btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.btn-large {
padding: 1rem 2rem;
font-size: 1.05rem;
display: block;
width: 100%;
}
.step-content a {
color: #667eea;
text-decoration: none;
font-weight: 600;
}
.step-content a:hover {
text-decoration: underline;
}
@media (max-width: 640px) {
.guide-modal {
padding: 1.5rem;
border-radius: 12px;
}
h1 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
.step {
padding: 1rem;
}
.step-header {
flex-direction: column;
gap: 0.5rem;
}
.step-header h2 {
font-size: 1.1rem;
}
.step-content {
margin-left: 0;
}
.close-btn {
font-size: 1.25rem;
}
}
</style>

View File

@@ -0,0 +1,95 @@
/**
* useChallongeApiKey Composable
* Manages Challonge API key storage in browser localStorage
* Works on mobile, desktop, and tablets
*/
import { ref, computed } from 'vue';
const STORAGE_KEY = 'challonge_api_key';
const storedKey = ref(getStoredKey());
/**
* Get API key from localStorage
* @returns {string|null} Stored API key or null
*/
function getStoredKey() {
try {
return localStorage.getItem(STORAGE_KEY) || null;
} catch (error) {
console.warn('localStorage not available:', error);
return null;
}
}
/**
* Save API key to localStorage
* @param {string} apiKey - The API key to store
* @returns {boolean} Success status
*/
function saveApiKey(apiKey) {
try {
if (!apiKey || typeof apiKey !== 'string') {
throw new Error('Invalid API key format');
}
localStorage.setItem(STORAGE_KEY, apiKey);
storedKey.value = apiKey;
return true;
} catch (error) {
console.error('Failed to save API key:', error);
return false;
}
}
/**
* Clear API key from localStorage
* @returns {boolean} Success status
*/
function clearApiKey() {
try {
localStorage.removeItem(STORAGE_KEY);
storedKey.value = null;
return true;
} catch (error) {
console.error('Failed to clear API key:', error);
return false;
}
}
/**
* Get masked version of API key for display
* Shows first 4 and last 4 characters
* @returns {string|null} Masked key or null
*/
const maskedKey = computed(() => {
if (!storedKey.value) return null;
const key = storedKey.value;
if (key.length < 8) return '••••••••';
return `${key.slice(0, 4)}•••••••${key.slice(-4)}`;
});
/**
* Check if API key is stored
* @returns {boolean} True if key exists
*/
const isKeyStored = computed(() => !!storedKey.value);
/**
* Get the full API key (use with caution)
* @returns {string|null} Full API key or null
*/
function getApiKey() {
return storedKey.value;
}
export function useChallongeApiKey() {
return {
saveApiKey,
clearApiKey,
getApiKey,
getStoredKey,
storedKey: computed(() => storedKey.value),
maskedKey,
isKeyStored
};
}

View File

@@ -0,0 +1,301 @@
/**
* Challonge OAuth Composable
*
* Manages OAuth authentication flow and token storage for Challonge API v2.1
*
* Features:
* - Authorization URL generation
* - Token exchange and storage
* - Automatic token refresh
* - Secure token management
*/
import { ref, computed } from 'vue';
const STORAGE_KEY = 'challonge_oauth_tokens';
const CLIENT_ID = import.meta.env.VITE_CHALLONGE_CLIENT_ID;
const REDIRECT_URI =
import.meta.env.VITE_CHALLONGE_REDIRECT_URI ||
`${window.location.origin}/oauth/callback`;
// Shared state across all instances
const tokens = ref(null);
const loading = ref(false);
const error = ref(null);
// Load tokens from localStorage on module initialization
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
tokens.value = JSON.parse(stored);
// Check if token is expired
if (tokens.value.expires_at && Date.now() >= tokens.value.expires_at) {
console.log('🔄 Token expired, will need to refresh');
}
}
} catch (err) {
console.error('Failed to load OAuth tokens:', err);
}
export function useChallongeOAuth() {
const isAuthenticated = computed(() => {
return !!tokens.value?.access_token;
});
const isExpired = computed(() => {
if (!tokens.value?.expires_at) return false;
return Date.now() >= tokens.value.expires_at;
});
const accessToken = computed(() => {
return tokens.value?.access_token || null;
});
/**
* Generate authorization URL for OAuth flow
* @param {string} scope - Requested scope (default: 'tournaments:read tournaments:write')
* @param {string} state - Optional state parameter (will be generated if not provided)
* @returns {Object} Object with authUrl and state
*/
function getAuthorizationUrl(
scope = 'tournaments:read tournaments:write',
state = null
) {
if (!CLIENT_ID) {
throw new Error('VITE_CHALLONGE_CLIENT_ID not configured');
}
// Generate state if not provided
const oauthState = state || generateState();
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: scope,
state: oauthState
});
return {
authUrl: `https://api.challonge.com/oauth/authorize?${params.toString()}`,
state: oauthState
};
}
/**
* Start OAuth authorization flow
* @param {string} scope - Requested scope
*/
function login(scope) {
try {
// Generate auth URL and state
const { authUrl, state } = getAuthorizationUrl(scope);
// Store state for CSRF protection
sessionStorage.setItem('oauth_state', state);
console.log('🔐 Starting OAuth flow with state:', state);
// Redirect to Challonge authorization page
window.location.href = authUrl;
} catch (err) {
error.value = err.message;
console.error('OAuth login error:', err);
}
}
/**
* Exchange authorization code for access token
* @param {string} code - Authorization code from callback
* @param {string} state - State parameter for CSRF protection
*/
async function exchangeCode(code, state) {
// Verify state parameter
const storedState = sessionStorage.getItem('oauth_state');
console.log('🔐 OAuth callback verification:');
console.log(' Received state:', state);
console.log(' Stored state:', storedState);
console.log(' Match:', state === storedState);
if (state !== storedState) {
console.error(
'❌ State mismatch! Possible CSRF attack or session issue.'
);
throw new Error('Invalid state parameter - possible CSRF attack');
}
loading.value = true;
error.value = null;
try {
const response = await fetch('/api/oauth/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ code })
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.error_description ||
errorData.error ||
'Token exchange failed'
);
}
const data = await response.json();
// Calculate expiration time
const expiresAt = Date.now() + data.expires_in * 1000;
tokens.value = {
access_token: data.access_token,
refresh_token: data.refresh_token,
token_type: data.token_type,
expires_in: data.expires_in,
expires_at: expiresAt,
scope: data.scope,
created_at: Date.now()
};
// Store tokens
localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens.value));
sessionStorage.removeItem('oauth_state');
console.log('✅ OAuth authentication successful');
return tokens.value;
} catch (err) {
error.value = err.message;
console.error('Token exchange error:', err);
throw err;
} finally {
loading.value = false;
}
}
/**
* Refresh access token using refresh token
*/
async function refreshToken() {
if (!tokens.value?.refresh_token) {
throw new Error('No refresh token available');
}
loading.value = true;
error.value = null;
try {
const response = await fetch('/api/oauth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
refresh_token: tokens.value.refresh_token
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.error_description ||
errorData.error ||
'Token refresh failed'
);
}
const data = await response.json();
// Calculate expiration time
const expiresAt = Date.now() + data.expires_in * 1000;
tokens.value = {
access_token: data.access_token,
refresh_token: data.refresh_token || tokens.value.refresh_token, // Keep old if not provided
token_type: data.token_type,
expires_in: data.expires_in,
expires_at: expiresAt,
scope: data.scope,
refreshed_at: Date.now()
};
// Store updated tokens
localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens.value));
console.log('✅ Token refreshed successfully');
return tokens.value;
} catch (err) {
error.value = err.message;
console.error('Token refresh error:', err);
// If refresh fails, clear tokens and force re-authentication
logout();
throw err;
} finally {
loading.value = false;
}
}
/**
* Get valid access token (refreshes if expired)
*/
async function getValidToken() {
if (!tokens.value) {
throw new Error('Not authenticated');
}
// If token is expired or about to expire (within 5 minutes), refresh it
const expiresIn = tokens.value.expires_at - Date.now();
const fiveMinutes = 5 * 60 * 1000;
if (expiresIn < fiveMinutes) {
console.log('🔄 Token expired or expiring soon, refreshing...');
await refreshToken();
}
return tokens.value.access_token;
}
/**
* Logout and clear tokens
*/
function logout() {
tokens.value = null;
localStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem('oauth_state');
console.log('👋 Logged out');
}
/**
* Generate random state for CSRF protection
*/
function generateState() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(
''
);
}
return {
// State
tokens: computed(() => tokens.value),
isAuthenticated,
isExpired,
accessToken,
loading: computed(() => loading.value),
error: computed(() => error.value),
// Methods
login,
logout,
exchangeCode,
refreshToken,
getValidToken,
getAuthorizationUrl
};
}

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import './style.css';
createApp(App).mount('#app');
createApp(App).use(router).mount('#app');

View File

@@ -0,0 +1,41 @@
import { createRouter, createWebHistory } from 'vue-router';
import Home from '../views/Home.vue';
import GamemasterManager from '../views/GamemasterManager.vue';
import ChallongeTest from '../views/ChallongeTest.vue';
import ApiKeyManager from '../views/ApiKeyManager.vue';
import OAuthCallback from '../views/OAuthCallback.vue';
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/gamemaster',
name: 'GamemasterManager',
component: GamemasterManager
},
{
path: '/challonge-test',
name: 'ChallongeTest',
component: ChallongeTest
},
{
path: '/api-key-manager',
name: 'ApiKeyManager',
component: ApiKeyManager
},
{
path: '/oauth/callback',
name: 'OAuthCallback',
component: OAuthCallback
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
export default router;

View File

@@ -0,0 +1,187 @@
/**
* Challonge API v1 Service (DEPRECATED - REFERENCE ONLY)
*
* ⚠️ DEPRECATED: This service is maintained for reference purposes only.
* Use challonge-v2.1.service.js for new development.
*
* Client for interacting with Challonge tournament platform API v1
* Adapted from Discord bot for Vue 3 browser environment
*/
import { API_CONFIG } from '../utilities/constants.js';
/**
* Get the appropriate base URL based on environment
* Development: Use Vite proxy to avoid CORS
* Production: Use direct API (requires backend proxy or CORS handling)
*/
function getBaseURL() {
// In development, use Vite proxy
if (import.meta.env.DEV) {
return '/api/challonge/v1/';
}
// In production, use direct API (will need backend proxy for CORS)
return API_CONFIG.CHALLONGE_BASE_URL;
}
/**
* Create Challonge API v1 client
* @param {string} apiKey - Challonge API v1 key
* @returns {Object} API client with methods
*/
export function createChallongeV1Client(apiKey) {
const baseURL = getBaseURL();
/**
* Make API request
* @param {string} endpoint - API endpoint
* @param {Object} options - Fetch options
* @returns {Promise<Object>} Response data
*/
async function makeRequest(endpoint, options = {}) {
const cleanEndpoint = endpoint.startsWith('/')
? endpoint.slice(1)
: endpoint;
const url = new URL(`${baseURL}${cleanEndpoint}`, window.location.origin);
url.searchParams.append('api_key', apiKey);
if (options.params) {
Object.entries(options.params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.append(key, value);
}
});
}
const fetchOptions = {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers
}
};
if (options.body) {
fetchOptions.body = JSON.stringify(options.body);
}
try {
const response = await fetch(url.toString(), fetchOptions);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(
error.errors?.[0] || `HTTP ${response.status}: ${response.statusText}`
);
}
return await response.json();
} catch (error) {
console.error('Challonge API v1 Error:', error);
throw error;
}
}
// Tournament Methods
const tournaments = {
list: params => makeRequest('tournaments', { params }),
get: (id, options = {}) =>
makeRequest(`tournaments/${id}`, {
params: {
include_participants: options.includeParticipants ? 1 : 0,
include_matches: options.includeMatches ? 1 : 0
}
}),
create: data =>
makeRequest('tournaments', {
method: 'POST',
body: { tournament: data }
}),
update: (id, data) =>
makeRequest(`tournaments/${id}`, {
method: 'PUT',
body: { tournament: data }
}),
delete: id => makeRequest(`tournaments/${id}`, { method: 'DELETE' }),
start: (id, options = {}) =>
makeRequest(`tournaments/${id}/start`, {
method: 'POST',
params: options
}),
finalize: id =>
makeRequest(`tournaments/${id}/finalize`, { method: 'POST' }),
reset: id => makeRequest(`tournaments/${id}/reset`, { method: 'POST' })
};
// Participant Methods
const participants = {
list: tournamentId =>
makeRequest(`tournaments/${tournamentId}/participants`),
add: (tournamentId, data) =>
makeRequest(`tournaments/${tournamentId}/participants`, {
method: 'POST',
body: { participant: data }
}),
bulkAdd: (tournamentId, participants) =>
makeRequest(`tournaments/${tournamentId}/participants/bulk_add`, {
method: 'POST',
body: { participants }
}),
update: (tournamentId, participantId, data) =>
makeRequest(`tournaments/${tournamentId}/participants/${participantId}`, {
method: 'PUT',
body: { participant: data }
}),
delete: (tournamentId, participantId) =>
makeRequest(`tournaments/${tournamentId}/participants/${participantId}`, {
method: 'DELETE'
}),
checkIn: (tournamentId, participantId) =>
makeRequest(
`tournaments/${tournamentId}/participants/${participantId}/check_in`,
{ method: 'POST' }
),
undoCheckIn: (tournamentId, participantId) =>
makeRequest(
`tournaments/${tournamentId}/participants/${participantId}/undo_check_in`,
{ method: 'POST' }
),
randomize: tournamentId =>
makeRequest(`tournaments/${tournamentId}/participants/randomize`, {
method: 'POST'
})
};
// Match Methods
const matches = {
list: (tournamentId, params = {}) =>
makeRequest(`tournaments/${tournamentId}/matches`, { params }),
get: (tournamentId, matchId) =>
makeRequest(`tournaments/${tournamentId}/matches/${matchId}`),
update: (tournamentId, matchId, data) =>
makeRequest(`tournaments/${tournamentId}/matches/${matchId}`, {
method: 'PUT',
body: { match: data }
}),
reopen: (tournamentId, matchId) =>
makeRequest(`tournaments/${tournamentId}/matches/${matchId}/reopen`, {
method: 'POST'
}),
markAsUnderway: (tournamentId, matchId) =>
makeRequest(
`tournaments/${tournamentId}/matches/${matchId}/mark_as_underway`,
{ method: 'POST' }
),
unmarkAsUnderway: (tournamentId, matchId) =>
makeRequest(
`tournaments/${tournamentId}/matches/${matchId}/unmark_as_underway`,
{ method: 'POST' }
)
};
return { tournaments, participants, matches };
}
// Backwards compatibility export
export const createChallongeClient = createChallongeV1Client;

View File

@@ -0,0 +1,553 @@
/**
* Challonge API v2.1 Service
* Client for interacting with Challonge API v2.1 (current version)
*
* Features:
* - OAuth 2.0 support (Bearer tokens)
* - API v1 key compatibility
* - JSON:API specification compliant
* - Tournament, Participant, Match, Race endpoints
* - Community and Application scoping
*
* @see https://challonge.apidog.io/getting-started-1726706m0
* @see https://challonge.apidog.io/llms.txt
*/
/**
* Get the appropriate base URL based on environment
*/
function getBaseURL() {
if (import.meta.env.DEV) {
return '/api/challonge/v2.1';
}
return 'https://api.challonge.com/v2.1';
}
/**
* Authentication types for Challonge API v2.1
*/
export const AuthType = {
OAUTH: 'v2', // Bearer token
API_KEY: 'v1' // Legacy API key
};
/**
* Resource scoping options
*/
export const ScopeType = {
USER: 'user', // /v2.1/tournaments (default)
COMMUNITY: 'community', // /v2.1/communities/{id}/tournaments
APPLICATION: 'app' // /v2.1/application/tournaments
};
/**
* Create Challonge API v2.1 client
*
* @param {Object} auth - Authentication configuration
* @param {string} auth.token - OAuth Bearer token or API v1 key
* @param {string} auth.type - AuthType.OAUTH or AuthType.API_KEY (default: API_KEY)
* @param {Object} options - Client options
* @param {string} options.communityId - Default community ID for scoping
* @param {boolean} options.debug - Enable debug logging
* @returns {Object} API client with methods
*/
export function createChallongeV2Client(auth, options = {}) {
const { token, type = AuthType.API_KEY } = auth;
const { communityId: defaultCommunityId, debug = false } = options;
const baseURL = getBaseURL();
if (!token) {
throw new Error('Authentication token is required');
}
// Request tracking for debug mode
let requestCount = 0;
/**
* Make API request with JSON:API format
*/
async function makeRequest(endpoint, options = {}) {
const {
method = 'GET',
body,
params = {},
headers = {},
communityId = defaultCommunityId,
scopeType = ScopeType.USER
} = options;
const startTime = performance.now();
requestCount++;
// Build URL with scoping
let url = baseURL;
if (scopeType === ScopeType.COMMUNITY && communityId) {
url += `/communities/${communityId}`;
} else if (scopeType === ScopeType.APPLICATION) {
url += '/application';
}
url += `/${endpoint}`;
// Add query parameters
const urlObj = new URL(url, window.location.origin);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
urlObj.searchParams.append(key, value);
}
});
// Add communityId as query param if not in path
if (communityId && scopeType === ScopeType.USER) {
urlObj.searchParams.append('community_id', communityId);
}
// Prepare headers (JSON:API required format)
const requestHeaders = {
'Content-Type': 'application/vnd.api+json',
Accept: 'application/json',
'Authorization-Type': type,
...headers
};
// Add authorization header
if (type === AuthType.OAUTH) {
requestHeaders['Authorization'] = `Bearer ${token}`;
} else {
requestHeaders['Authorization'] = token;
}
const fetchOptions = {
method,
headers: requestHeaders
};
if (body && method !== 'GET') {
// Wrap in JSON:API format if not already wrapped
const jsonApiBody = body.data ? body : { data: body };
fetchOptions.body = JSON.stringify(jsonApiBody);
}
if (debug) {
console.log(
`[Challonge v2.1 Request #${requestCount}]`,
method,
urlObj.toString()
);
if (body) console.log('Body:', fetchOptions.body);
}
try {
const response = await fetch(urlObj.toString(), fetchOptions);
const duration = performance.now() - startTime;
// Handle 204 No Content
if (response.status === 204) {
if (debug)
console.log(
`[Challonge v2.1 Response] 204 No Content (${duration.toFixed(0)}ms)`
);
return null;
}
let data;
try {
data = await response.json();
} catch (parseError) {
// If JSON parsing fails, create an error with the status
if (debug)
console.error('[Challonge v2.1 JSON Parse Error]', parseError);
const error = new Error(
`HTTP ${response.status}: Failed to parse response`
);
error.status = response.status;
throw error;
}
if (debug) {
console.log(
`[Challonge v2.1 Response] ${response.status} (${duration.toFixed(0)}ms)`,
data
);
}
// Handle JSON:API errors
if (!response.ok) {
if (data.errors && Array.isArray(data.errors)) {
const errorDetails = data.errors.map(e => ({
status: e.status || response.status,
message: e.detail || e.title || response.statusText,
field: e.source?.pointer
}));
const errorMessage = errorDetails
.map(
e => `${e.status}: ${e.message}${e.field ? ` (${e.field})` : ''}`
)
.join('\n');
const error = new Error(errorMessage);
error.errors = errorDetails;
error.response = data;
throw error;
}
// Handle non-JSON:API error format
const error = new Error(
`HTTP ${response.status}: ${data.message || response.statusText}`
);
error.status = response.status;
error.response = data;
throw error;
}
return data;
} catch (error) {
if (debug) {
console.error('[Challonge v2.1 Error]', error);
}
throw error;
}
}
/**
* Helper to unwrap JSON:API response and normalize structure
*/
function unwrapResponse(response) {
if (!response) return null;
// If response has data property, it's JSON:API format
if (response.data) {
const data = response.data;
// Handle array of resources
if (Array.isArray(data)) {
return data.map(item => normalizeResource(item));
}
// Handle single resource
return normalizeResource(data);
}
return response;
}
/**
* Normalize JSON:API resource to flat structure
*/
function normalizeResource(resource) {
if (!resource || !resource.attributes) return resource;
return {
id: resource.id,
type: resource.type,
...resource.attributes,
relationships: resource.relationships,
links: resource.links
};
}
// ==================== Tournament Methods ====================
const tournaments = {
/**
* List tournaments
* @param {Object} options - Query options
* @returns {Promise<Array>}
*/
list: async (options = {}) => {
const { communityId, scopeType, ...params } = options;
const response = await makeRequest('tournaments.json', {
params,
communityId,
scopeType
});
return unwrapResponse(response);
},
/**
* Get tournament details
* @param {string} id - Tournament ID or URL
* @param {Object} options - Options
* @returns {Promise<Object>}
*/
get: async (id, options = {}) => {
const { communityId, scopeType, ifNoneMatch } = options;
const response = await makeRequest(`tournaments/${id}.json`, {
communityId,
scopeType,
headers: ifNoneMatch ? { 'If-None-Match': ifNoneMatch } : {}
});
return unwrapResponse(response);
},
/**
* Create tournament
* @param {Object} data - Tournament data
* @param {Object} options - Options
* @returns {Promise<Object>}
*/
create: async (data, options = {}) => {
const { communityId, scopeType } = options;
const response = await makeRequest('tournaments.json', {
method: 'POST',
body: { type: 'Tournaments', attributes: data },
communityId,
scopeType
});
return unwrapResponse(response);
},
/**
* Update tournament
* @param {string} id - Tournament ID
* @param {Object} data - Updated fields
* @param {Object} options - Options
* @returns {Promise<Object>}
*/
update: async (id, data, options = {}) => {
const { communityId, scopeType } = options;
const response = await makeRequest(`tournaments/${id}.json`, {
method: 'PUT',
body: { type: 'Tournaments', attributes: data },
communityId,
scopeType
});
return unwrapResponse(response);
},
/**
* Delete tournament
* @param {string} id - Tournament ID
* @param {Object} options - Options
* @returns {Promise<null>}
*/
delete: async (id, options = {}) => {
const { communityId, scopeType } = options;
return await makeRequest(`tournaments/${id}.json`, {
method: 'DELETE',
communityId,
scopeType
});
},
/**
* Change tournament state
* @param {string} id - Tournament ID
* @param {string} state - New state
* @param {Object} options - Options
* @returns {Promise<Object>}
*/
changeState: async (id, state, options = {}) => {
const { communityId, scopeType } = options;
const response = await makeRequest(
`tournaments/${id}/change_state.json`,
{
method: 'PUT',
body: { type: 'TournamentState', attributes: { state } },
communityId,
scopeType
}
);
return unwrapResponse(response);
},
// Convenience methods
start: (id, options) => tournaments.changeState(id, 'start', options),
finalize: (id, options) => tournaments.changeState(id, 'finalize', options),
reset: (id, options) => tournaments.changeState(id, 'reset', options),
processCheckIn: (id, options) =>
tournaments.changeState(id, 'process_checkin', options)
};
// ==================== Participant Methods ====================
const participants = {
list: async (tournamentId, options = {}) => {
const { communityId, scopeType, page, per_page, ifNoneMatch } = options;
const response = await makeRequest(
`tournaments/${tournamentId}/participants.json`,
{
params: { page, per_page },
communityId,
scopeType,
headers: ifNoneMatch ? { 'If-None-Match': ifNoneMatch } : {}
}
);
return unwrapResponse(response);
},
get: async (tournamentId, participantId, options = {}) => {
const { communityId, scopeType } = options;
const response = await makeRequest(
`tournaments/${tournamentId}/participants/${participantId}.json`,
{ communityId, scopeType }
);
return unwrapResponse(response);
},
create: async (tournamentId, data, options = {}) => {
const { communityId, scopeType } = options;
const response = await makeRequest(
`tournaments/${tournamentId}/participants.json`,
{
method: 'POST',
body: { type: 'Participants', attributes: data },
communityId,
scopeType
}
);
return unwrapResponse(response);
},
update: async (tournamentId, participantId, data, options = {}) => {
const { communityId, scopeType } = options;
const response = await makeRequest(
`tournaments/${tournamentId}/participants/${participantId}.json`,
{
method: 'PUT',
body: { type: 'Participants', attributes: data },
communityId,
scopeType
}
);
return unwrapResponse(response);
},
delete: async (tournamentId, participantId, options = {}) => {
const { communityId, scopeType } = options;
return await makeRequest(
`tournaments/${tournamentId}/participants/${participantId}.json`,
{ method: 'DELETE', communityId, scopeType }
);
},
bulkAdd: async (tournamentId, participantsData, options = {}) => {
const { communityId, scopeType } = options;
const response = await makeRequest(
`tournaments/${tournamentId}/participants/bulk_add.json`,
{
method: 'POST',
body: {
type: 'Participants',
attributes: { participants: participantsData }
},
communityId,
scopeType
}
);
return unwrapResponse(response);
},
clear: async (tournamentId, options = {}) => {
const { communityId, scopeType } = options;
return await makeRequest(
`tournaments/${tournamentId}/participants/clear.json`,
{
method: 'DELETE',
communityId,
scopeType
}
);
},
randomize: async (tournamentId, options = {}) => {
const { communityId, scopeType } = options;
const response = await makeRequest(
`tournaments/${tournamentId}/participants/randomize.json`,
{ method: 'PUT', communityId, scopeType }
);
return unwrapResponse(response);
}
};
// ==================== Match Methods ====================
const matches = {
list: async (tournamentId, options = {}) => {
const {
communityId,
scopeType,
state,
participant_id,
page,
per_page,
ifNoneMatch
} = options;
const response = await makeRequest(
`tournaments/${tournamentId}/matches.json`,
{
params: { state, participant_id, page, per_page },
communityId,
scopeType,
headers: ifNoneMatch ? { 'If-None-Match': ifNoneMatch } : {}
}
);
return unwrapResponse(response);
},
get: async (tournamentId, matchId, options = {}) => {
const { communityId, scopeType } = options;
const response = await makeRequest(
`tournaments/${tournamentId}/matches/${matchId}.json`,
{ communityId, scopeType }
);
return unwrapResponse(response);
},
update: async (tournamentId, matchId, data, options = {}) => {
const { communityId, scopeType } = options;
const response = await makeRequest(
`tournaments/${tournamentId}/matches/${matchId}.json`,
{
method: 'PUT',
body: { type: 'Match', attributes: data },
communityId,
scopeType
}
);
return unwrapResponse(response);
},
changeState: async (tournamentId, matchId, state, options = {}) => {
const { communityId, scopeType } = options;
const response = await makeRequest(
`tournaments/${tournamentId}/matches/${matchId}/change_state.json`,
{
method: 'PUT',
body: { type: 'MatchState', attributes: { state } },
communityId,
scopeType
}
);
return unwrapResponse(response);
},
reopen: (tournamentId, matchId, options) =>
matches.changeState(tournamentId, matchId, 'reopen', options),
markAsUnderway: (tournamentId, matchId, options) =>
matches.changeState(tournamentId, matchId, 'mark_as_underway', options)
};
// ==================== User & Community Methods ====================
const user = {
getMe: async () => {
const response = await makeRequest('me.json');
return unwrapResponse(response);
}
};
const communities = {
list: async () => {
const response = await makeRequest('communities.json');
return unwrapResponse(response);
}
};
return {
tournaments,
participants,
matches,
user,
communities,
// Expose request count for debugging
getRequestCount: () => requestCount
};
}

View File

@@ -0,0 +1,30 @@
/**
* Challonge Service - Backwards Compatibility Wrapper
*
* This file maintains backwards compatibility by re-exporting both API versions.
*
* For new code, import directly from:
* - './challonge-v1.service.js' for legacy API (deprecated)
* - './challonge-v2.1.service.js' for current API (recommended)
*
* @example Using v2.1 (recommended)
* import { createChallongeV2Client, AuthType } from './challonge.service.js';
* const client = createChallongeV2Client({ token: apiKey, type: AuthType.API_KEY });
*
* @example Using v1 (backwards compatibility)
* import { createChallongeClient } from './challonge.service.js';
* const client = createChallongeClient(apiKey);
*/
// Primary exports (v2.1 - recommended for new code)
export {
createChallongeV2Client,
AuthType,
ScopeType
} from './challonge-v2.1.service.js';
// Legacy exports (v1 - backwards compatibility)
export {
createChallongeV1Client,
createChallongeClient
} from './challonge-v1.service.js';

View File

@@ -0,0 +1,65 @@
/**
* Application Constants
* Centralized configuration values for the Pokedex Online application
*/
export const API_CONFIG = {
CHALLONGE_BASE_URL: 'https://api.challonge.com/v1/',
TIMEOUT: 10000,
RETRY_ATTEMPTS: 3
};
export const UI_CONFIG = {
TOAST_DURATION: 5000,
DEBOUNCE_DELAY: 300,
ITEMS_PER_PAGE: 50
};
export const TOURNAMENT_TYPES = {
SINGLE_ELIMINATION: 'single_elimination',
DOUBLE_ELIMINATION: 'double_elimination',
ROUND_ROBIN: 'round_robin',
SWISS: 'swiss'
};
export const TOURNAMENT_STATES = {
PENDING: 'pending',
CHECKING_IN: 'checking_in',
CHECKED_IN: 'checked_in',
UNDERWAY: 'underway',
COMPLETE: 'complete'
};
export const POKEMON_TYPES = {
NORMAL: 'POKEMON_TYPE_NORMAL',
FIRE: 'POKEMON_TYPE_FIRE',
WATER: 'POKEMON_TYPE_WATER',
ELECTRIC: 'POKEMON_TYPE_ELECTRIC',
GRASS: 'POKEMON_TYPE_GRASS',
ICE: 'POKEMON_TYPE_ICE',
FIGHTING: 'POKEMON_TYPE_FIGHTING',
POISON: 'POKEMON_TYPE_POISON',
GROUND: 'POKEMON_TYPE_GROUND',
FLYING: 'POKEMON_TYPE_FLYING',
PSYCHIC: 'POKEMON_TYPE_PSYCHIC',
BUG: 'POKEMON_TYPE_BUG',
ROCK: 'POKEMON_TYPE_ROCK',
GHOST: 'POKEMON_TYPE_GHOST',
DRAGON: 'POKEMON_TYPE_DRAGON',
DARK: 'POKEMON_TYPE_DARK',
STEEL: 'POKEMON_TYPE_STEEL',
FAIRY: 'POKEMON_TYPE_FAIRY'
};
export const CSV_HEADERS = {
PLAYER_ID: 'player_id',
FIRST_NAME: 'first_name',
LAST_NAME: 'last_name',
COUNTRY_CODE: 'country_code',
DIVISION: 'division',
SCREENNAME: 'screenname',
EMAIL: 'email',
TOURNAMENT_ID: 'tournament_id'
};
export const EXPECTED_CSV_HEADERS = Object.values(CSV_HEADERS);

View File

@@ -0,0 +1,140 @@
/**
* CSV Parsing Utilities
* Functions for parsing and validating CSV files (RK9 player registrations)
*/
import { EXPECTED_CSV_HEADERS, CSV_HEADERS } from './constants.js';
/**
* Validate CSV headers against expected format
* @param {string[]} headers - Array of header names from CSV
* @throws {Error} If headers are invalid or missing required fields
*/
export function validateCsvHeaders(headers) {
if (!headers || headers.length === 0) {
throw new Error('CSV file is missing headers');
}
if (headers.length !== EXPECTED_CSV_HEADERS.length) {
throw new Error(
`Invalid CSV file headers: Expected ${EXPECTED_CSV_HEADERS.length} headers but found ${headers.length}`
);
}
const missingHeaders = EXPECTED_CSV_HEADERS.filter(
expectedHeader => !headers.includes(expectedHeader)
);
if (missingHeaders.length > 0) {
throw new Error(
`Invalid CSV file headers: Missing the following headers: ${missingHeaders.join(', ')}`
);
}
}
/**
* Parse CSV text content into structured player data
* @param {string} csvData - Raw CSV file content
* @returns {Object} Object keyed by screenname with player data
* @throws {Error} If CSV format is invalid
*/
export function parseCsv(csvData) {
const rows = csvData
.split('\n')
.map(row => row.split(','))
.filter(row => row.some(cell => cell.trim() !== ''));
if (rows.length === 0) {
throw new Error('CSV file is empty');
}
const headers = rows[0].map(header => header.trim());
validateCsvHeaders(headers);
// Validate row format
for (let i = 1; i < rows.length; i++) {
if (rows[i].length !== EXPECTED_CSV_HEADERS.length) {
throw new Error(`Invalid row format at line ${i + 1}`);
}
}
// Parse rows into objects
return rows.slice(1).reduce((acc, row) => {
const participant = {};
EXPECTED_CSV_HEADERS.forEach((header, idx) => {
participant[header] = row[idx]?.trim();
});
acc[participant[CSV_HEADERS.SCREENNAME]] = participant;
return acc;
}, {});
}
/**
* Parse CSV file from browser File API
* @param {File} file - File object from input[type=file]
* @returns {Promise<Object>} Parsed player data
*/
export async function parsePlayerCsvFile(file) {
if (!file) {
throw new Error('No file provided');
}
if (!file.name.endsWith('.csv')) {
throw new Error('File must be a CSV file');
}
const text = await file.text();
return parseCsv(text);
}
/**
* Convert parsed CSV data to array format
* @param {Object} csvObject - Object from parseCsv
* @returns {Array} Array of player objects with screenname included
*/
export function csvObjectToArray(csvObject) {
return Object.entries(csvObject).map(([screenname, data]) => ({
...data,
screenname
}));
}
/**
* Validate individual player data
* @param {Object} player - Player data object
* @returns {Object} Validation result {valid: boolean, errors: string[]}
*/
export function validatePlayerData(player) {
const errors = [];
if (!player[CSV_HEADERS.PLAYER_ID]) {
errors.push('Missing player_id');
}
if (!player[CSV_HEADERS.SCREENNAME]) {
errors.push('Missing screenname');
}
if (!player[CSV_HEADERS.DIVISION]) {
errors.push('Missing division');
}
const email = player[CSV_HEADERS.EMAIL];
if (email && !isValidEmail(email)) {
errors.push('Invalid email format');
}
return {
valid: errors.length === 0,
errors
};
}
/**
* Simple email validation
* @param {string} email - Email address to validate
* @returns {boolean} True if email format is valid
*/
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

View File

@@ -0,0 +1,51 @@
/**
* Debug Logging Utility
*
* Provides simple debug logging that can be toggled via environment variable
* or browser console: localStorage.setItem('DEBUG', '1')
*
* Usage:
* import { debug } from '../utilities/debug.js'
* debug('info', 'message', data)
* debug('error', 'message', error)
*/
const DEBUG_ENABLED = () => {
// Check environment variable
if (import.meta.env.VITE_DEBUG === 'true') {
return true;
}
// Check localStorage for quick toggle in browser
try {
return localStorage.getItem('DEBUG') === '1';
} catch {
return false;
}
};
export function debug(level, message, data = null) {
if (!DEBUG_ENABLED()) {
return;
}
const timestamp = new Date().toLocaleTimeString();
const prefix = `[${timestamp}] ${level.toUpperCase()}:`;
if (data) {
console[level === 'error' ? 'error' : 'log'](prefix, message, data);
} else {
console[level === 'error' ? 'error' : 'log'](prefix, message);
}
}
export function debugInfo(message, data = null) {
debug('info', message, data);
}
export function debugError(message, error = null) {
debug('error', message, error);
}
export function debugWarn(message, data = null) {
debug('warn', message, data);
}

View File

@@ -0,0 +1,139 @@
/**
* Gamemaster Utilities
* Functions for fetching and processing PokeMiners gamemaster data
*/
const POKEMINERS_GAMEMASTER_URL =
'https://raw.githubusercontent.com/PokeMiners/game_masters/master/latest/latest.json';
/**
* Fetch latest gamemaster data from PokeMiners GitHub
* @returns {Promise<Array>} Gamemaster data array
*/
export async function fetchLatestGamemaster() {
try {
const response = await fetch(POKEMINERS_GAMEMASTER_URL);
if (!response.ok) {
throw new Error(`Failed to fetch gamemaster: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching gamemaster:', error);
throw error;
}
}
/**
* Break up gamemaster into separate categories
* @param {Array} gamemaster - Full gamemaster data
* @returns {Object} Separated data {pokemon, pokemonAllForms, moves}
*/
export function breakUpGamemaster(gamemaster) {
const regionCheck = ['alola', 'galarian', 'hisuian', 'paldea'];
const result = gamemaster.reduce(
(acc, item) => {
const templateId = item.templateId;
// POKEMON FILTER
// If the templateId begins with 'V' AND includes 'pokemon'
if (
templateId.startsWith('V') &&
templateId.toLowerCase().includes('pokemon')
) {
const pokemonSettings = item.data?.pokemonSettings;
const pokemonId = pokemonSettings?.pokemonId;
// Add to allFormsCostumes (includes everything)
acc.pokemonAllForms.push(item);
// Add to pokemon (filtered - first occurrence OR regional forms)
if (
!acc.pokemonSeen.has(pokemonId) ||
(acc.pokemonSeen.has(pokemonId) &&
regionCheck.includes(
pokemonSettings?.form?.split('_')[1]?.toLowerCase()
))
) {
acc.pokemonSeen.add(pokemonId);
acc.pokemon.push(item);
}
}
// POKEMON MOVE FILTER
if (
templateId.startsWith('V') &&
templateId.toLowerCase().includes('move')
) {
const moveSettings = item.data?.moveSettings;
const moveId = moveSettings?.movementId;
if (!acc.moveSeen.has(moveId)) {
acc.moveSeen.add(moveId);
acc.moves.push(item);
}
}
return acc;
},
{
pokemon: [],
pokemonAllForms: [],
moves: [],
pokemonSeen: new Set(),
moveSeen: new Set()
}
);
// Clean up the Sets before returning
delete result.pokemonSeen;
delete result.moveSeen;
return result;
}
/**
* Download JSON data as a file
* @param {Object|Array} data - Data to download
* @param {string} filename - Filename for download
*/
export function downloadJson(data, filename) {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Calculate file size in MB
* @param {Object|Array} data - Data to measure
* @returns {string} Size in MB formatted
*/
export function calculateFileSize(data) {
const json = JSON.stringify(data);
const bytes = new Blob([json]).size;
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(2)} MB`;
}
/**
* Get statistics about gamemaster data
* @param {Object} brokenUpData - Result from breakUpGamemaster
* @returns {Object} Statistics
*/
export function getGamemasterStats(brokenUpData) {
return {
pokemonCount: brokenUpData.pokemon.length,
allFormsCount: brokenUpData.pokemonAllForms.length,
movesCount: brokenUpData.moves.length,
pokemonSize: calculateFileSize(brokenUpData.pokemon),
allFormsSize: calculateFileSize(brokenUpData.pokemonAllForms),
movesSize: calculateFileSize(brokenUpData.moves)
};
}

View File

@@ -0,0 +1,155 @@
/**
* Participant Model
* Represents a tournament participant with Challonge and RK9 data
*/
export class ParticipantModel {
constructor(participant = {}) {
// Challonge Data
this.id = participant.id;
this.tournamentId = participant.tournament_id;
this.name = participant.name;
this.seed = participant.seed || 0;
this.misc = participant.misc || ''; // Can store player ID or notes
// Status
this.active = participant.active !== false;
this.checkedIn = !!participant.checked_in_at;
this.checkedInAt = participant.checked_in_at
? new Date(participant.checked_in_at)
: null;
this.createdAt = participant.created_at
? new Date(participant.created_at)
: null;
this.updatedAt = participant.updated_at
? new Date(participant.updated_at)
: null;
// Tournament Performance
this.finalRank = participant.final_rank || null;
this.wins = participant.wins || 0;
this.losses = participant.losses || 0;
this.ties = participant.ties || 0;
// RK9 Integration Data (merged after CSV import)
this.rk9Data = participant.rk9Data || null;
this.printIndex = participant.printIndex || null;
// Group/Pool assignment (for swiss/round robin)
this.groupPlayerId = participant.group_player_ids?.[0] || null;
// Custom Data
this.customFieldResponses = participant.custom_field_responses || null;
}
/**
* Get full player name from RK9 data if available
* @returns {string}
*/
getFullName() {
if (this.rk9Data?.first_name && this.rk9Data?.last_name) {
return `${this.rk9Data.first_name} ${this.rk9Data.last_name}`;
}
return this.name;
}
/**
* Get player division from RK9 data
* @returns {string}
*/
getDivision() {
return this.rk9Data?.division || 'Unknown';
}
/**
* Get player email from RK9 data
* @returns {string|null}
*/
getEmail() {
return this.rk9Data?.email || null;
}
/**
* Get player ID from RK9 data
* @returns {string|null}
*/
getPlayerId() {
return this.rk9Data?.player_id || null;
}
/**
* Calculate win rate
* @returns {number} Win rate as decimal (0-1)
*/
getWinRate() {
const total = this.wins + this.losses + this.ties;
return total > 0 ? this.wins / total : 0;
}
/**
* Get total matches played
* @returns {number}
*/
getMatchesPlayed() {
return this.wins + this.losses + this.ties;
}
/**
* Check if participant has RK9 registration data
* @returns {boolean}
*/
hasRegistrationData() {
return !!this.rk9Data;
}
/**
* Check if participant is checked in
* @returns {boolean}
*/
isCheckedIn() {
return this.checkedIn;
}
/**
* Check if participant is still active in tournament
* @returns {boolean}
*/
isActive() {
return this.active;
}
/**
* Get match record string (W-L-T)
* @returns {string}
*/
getRecord() {
return `${this.wins}-${this.losses}-${this.ties}`;
}
/**
* Format participant data for display
* @returns {Object}
*/
toDisplayFormat() {
return {
id: this.id,
name: this.name,
fullName: this.getFullName(),
seed: this.seed,
division: this.getDivision(),
record: this.getRecord(),
winRate: Math.round(this.getWinRate() * 100),
rank: this.finalRank,
checkedIn: this.checkedIn,
hasRegistration: this.hasRegistrationData()
};
}
/**
* Validate participant data
* @returns {boolean}
*/
isValid() {
return !!(this.id && this.name);
}
}

View File

@@ -0,0 +1,198 @@
/**
* Pokemon Model
* Represents Pokemon data from PokeMiners gamemaster files
*/
import {
extractPokedexNumber,
pokemonIdToDisplayName,
formatPokemonType
} from '../string-utils.js';
export class PokemonModel {
constructor(pokemonData = {}) {
const settings = pokemonData.data?.pokemonSettings || {};
// Identity
this.templateId = pokemonData.templateId;
this.pokemonId = settings.pokemonId;
this.form = settings.form;
this.dexNumber = extractPokedexNumber(this.templateId);
// Types
this.type = settings.type;
this.type2 = settings.type2 || null;
// Base Stats
this.stats = {
hp: settings.stats?.baseStamina || 0,
atk: settings.stats?.baseAttack || 0,
def: settings.stats?.baseDefense || 0
};
// Moves
this.quickMoves = settings.quickMoves || [];
this.cinematicMoves = settings.cinematicMoves || [];
this.eliteQuickMoves = settings.eliteQuickMove || [];
this.eliteCinematicMoves = settings.eliteCinematicMove || [];
// Evolution
this.evolutionIds = settings.evolutionIds || [];
this.evolutionBranch = settings.evolutionBranch || [];
this.candyToEvolve = settings.candyToEvolve || 0;
this.familyId = settings.familyId;
// Pokedex Info
this.heightM = settings.pokedexHeightM || 0;
this.weightKg = settings.pokedexWeightKg || 0;
// Buddy System
this.kmBuddyDistance = settings.kmBuddyDistance || 0;
this.buddyScale = settings.buddyScale || 1;
this.buddyPortraitOffset = settings.buddyPortraitOffset || [0, 0, 0];
// Camera Settings
this.camera = {
diskRadius: settings.camera?.diskRadiusM || 0,
cylinderRadius: settings.camera?.cylinderRadiusM || 0,
cylinderHeight: settings.camera?.cylinderHeightM || 0
};
// Encounter Settings
this.encounter = {
baseCaptureRate: settings.encounter?.baseCaptureRate || 0,
baseFleeRate: settings.encounter?.baseFleeRate || 0,
collisionRadius: settings.encounter?.collisionRadiusM || 0,
collisionHeight: settings.encounter?.collisionHeightM || 0,
movementType: settings.encounter?.movementType || 'MOVEMENT_WALK'
};
// Shadow Pokemon
this.shadow = settings.shadow
? {
purificationStardustNeeded:
settings.shadow.purificationStardustNeeded || 0,
purificationCandyNeeded: settings.shadow.purificationCandyNeeded || 0,
purifiedChargeMove: settings.shadow.purifiedChargeMove || null,
shadowChargeMove: settings.shadow.shadowChargeMove || null
}
: null;
// Flags
this.isTransferable = settings.isTransferable !== false;
this.isTradable = settings.isTradable !== false;
this.isDeployable = settings.isDeployable !== false;
this.isMega = !!settings.tempEvoOverrides;
}
/**
* Get display-friendly name
* @returns {string}
*/
get displayName() {
return pokemonIdToDisplayName(this.pokemonId);
}
/**
* Get formatted types for display
* @returns {string[]}
*/
get displayTypes() {
const types = [formatPokemonType(this.type)];
if (this.type2) {
types.push(formatPokemonType(this.type2));
}
return types;
}
/**
* Calculate total base stats
* @returns {number}
*/
get totalStats() {
return this.stats.hp + this.stats.atk + this.stats.def;
}
/**
* Check if Pokemon has an evolution
* @returns {boolean}
*/
hasEvolution() {
return this.evolutionBranch.length > 0;
}
/**
* Check if Pokemon is a shadow form
* @returns {boolean}
*/
isShadow() {
return !!this.shadow;
}
/**
* Check if Pokemon has elite moves
* @returns {boolean}
*/
hasEliteMoves() {
return (
this.eliteQuickMoves.length > 0 || this.eliteCinematicMoves.length > 0
);
}
/**
* Get all available moves (quick + charged)
* @returns {Object}
*/
getAllMoves() {
return {
quick: [...this.quickMoves, ...this.eliteQuickMoves],
charged: [...this.cinematicMoves, ...this.eliteCinematicMoves]
};
}
/**
* Check if Pokemon can mega evolve
* @returns {boolean}
*/
canMegaEvolve() {
return this.isMega;
}
/**
* Get evolution details
* @returns {Array}
*/
getEvolutions() {
return this.evolutionBranch.map(evo => ({
evolution: evo.evolution,
candyCost: evo.candyCost || 0,
form: evo.form,
itemRequirement: evo.evolutionItemRequirement || null
}));
}
/**
* Format for display/export
* @returns {Object}
*/
toDisplayFormat() {
return {
dexNumber: this.dexNumber,
name: this.displayName,
types: this.displayTypes,
stats: this.stats,
totalStats: this.totalStats,
canEvolve: this.hasEvolution(),
isShadow: this.isShadow(),
hasEliteMoves: this.hasEliteMoves()
};
}
/**
* Validate Pokemon data
* @returns {boolean}
*/
isValid() {
return !!(this.templateId && this.pokemonId && this.type);
}
}

View File

@@ -0,0 +1,146 @@
/**
* Tournament Model
* Normalizes Challonge tournament data into a structured object
*/
import { TOURNAMENT_TYPES, TOURNAMENT_STATES } from '../constants.js';
export class TournamentModel {
constructor(tournament = {}) {
// Core Properties
this.id = tournament.id;
this.name = tournament.name;
this.url = tournament.url;
this.tournamentType =
tournament.tournament_type || TOURNAMENT_TYPES.SINGLE_ELIMINATION;
this.state = tournament.state || TOURNAMENT_STATES.PENDING;
// Scheduling
this.startDate = tournament.start_at ? new Date(tournament.start_at) : null;
this.startedAt = tournament.started_at
? new Date(tournament.started_at)
: null;
this.completedAt = tournament.completed_at
? new Date(tournament.completed_at)
: null;
this.checkInDuration = tournament.check_in_duration || 0;
this.startedCheckingInAt = tournament.started_checking_in_at
? new Date(tournament.started_checking_in_at)
: null;
// Scoring Configuration
this.pointsForMatchWin = parseFloat(tournament.pts_for_match_win) || 1.0;
this.pointsForMatchTie = parseFloat(tournament.pts_for_match_tie) || 0.5;
this.pointsForGameWin = parseFloat(tournament.pts_for_game_win) || 0.0;
this.pointsForGameTie = parseFloat(tournament.pts_for_game_tie) || 0.0;
this.pointsForBye = parseFloat(tournament.pts_for_bye) || 1.0;
// Swiss/Round Robin Settings
this.swissRounds = tournament.swiss_rounds || 0;
this.rankedBy = tournament.ranked_by || 'match wins';
// Participants
this.participantsCount = tournament.participants_count || 0;
this.signupCap = tournament.signup_cap || null;
this.participants = tournament.participants || [];
// Matches
this.matches = tournament.matches || [];
// Settings
this.openSignup = tournament.open_signup || false;
this.private = tournament.private || false;
this.showRounds = tournament.show_rounds || false;
this.sequentialPairings = tournament.sequential_pairings || false;
this.acceptAttachments = tournament.accept_attachments || false;
this.hideForum = tournament.hide_forum || false;
this.notifyUsersWhenMatchesOpen =
tournament.notify_users_when_matches_open || false;
this.notifyUsersWhenTournamentEnds =
tournament.notify_users_when_the_tournament_ends || false;
// Grand Finals
this.grandFinalsModifier = tournament.grand_finals_modifier || null;
this.holdThirdPlaceMatch = tournament.hold_third_place_match || false;
// Description
this.description = tournament.description || '';
this.subdomain = tournament.subdomain || null;
// Full tournament URL
this.fullUrl = this.subdomain
? `https://${this.subdomain}.challonge.com/${this.url}`
: `https://challonge.com/${this.url}`;
}
/**
* Check if tournament is currently active
* @returns {boolean}
*/
isActive() {
return (
this.state === TOURNAMENT_STATES.UNDERWAY ||
this.state === TOURNAMENT_STATES.CHECKING_IN ||
this.state === TOURNAMENT_STATES.CHECKED_IN
);
}
/**
* Check if tournament is complete
* @returns {boolean}
*/
isComplete() {
return this.state === TOURNAMENT_STATES.COMPLETE;
}
/**
* Check if tournament is accepting signups
* @returns {boolean}
*/
isAcceptingSignups() {
return this.openSignup && this.state === TOURNAMENT_STATES.PENDING;
}
/**
* Check if tournament has reached signup cap
* @returns {boolean}
*/
isAtCapacity() {
if (!this.signupCap) return false;
return this.participantsCount >= this.signupCap;
}
/**
* Get tournament duration in milliseconds
* @returns {number|null}
*/
getDuration() {
if (!this.startedAt || !this.completedAt) return null;
return this.completedAt.getTime() - this.startedAt.getTime();
}
/**
* Format duration as human-readable string
* @returns {string}
*/
getFormattedDuration() {
const duration = this.getDuration();
if (!duration) return 'N/A';
const hours = Math.floor(duration / (1000 * 60 * 60));
const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
/**
* Validate tournament data
* @returns {boolean}
*/
isValid() {
return !!(this.id && this.name && this.tournamentType);
}
}

View File

@@ -0,0 +1,129 @@
/**
* Participant Utility Functions
* Functions for merging and managing tournament participant data
*/
import { normalizeScreenname } from './string-utils.js';
/**
* Merge RK9 registration data with Challonge tournament participants
* Matches participants by normalized screenname
* @param {Object} rk9Participants - Object of RK9 players keyed by screenname
* @param {Object} participantsById - Object of Challonge participants keyed by ID
* @returns {Object} {participantsById: merged data, issues: unmatched names}
*/
export function mergeRK9Participants(rk9Participants, participantsById) {
// Create normalized lookup map for RK9 data
const normalizedRK9 = Object.fromEntries(
Object.entries(rk9Participants).map(([key, value]) => [
normalizeScreenname(key),
value
])
);
// Match Challonge participants to RK9 data
Object.values(participantsById).forEach(participant => {
const normalized = normalizeScreenname(participant.name);
const rk9Participant = normalizedRK9[normalized];
if (rk9Participant) {
participant.rk9Data = rk9Participant;
// Track print order based on RK9 registration order
participant.printIndex =
Object.keys(normalizedRK9).indexOf(normalized) + 1;
}
});
// Collect participants without rk9Data for reporting issues
const issues = Object.values(participantsById).reduce((acc, participant) => {
if (!participant.rk9Data) {
acc.push(participant.name);
}
return acc;
}, []);
return { participantsById, issues };
}
/**
* Sort participants by seed number
* @param {Array} participants - Array of participant objects
* @returns {Array} Sorted array
*/
export function sortParticipantsBySeed(participants) {
return [...participants].sort((a, b) => a.seed - b.seed);
}
/**
* Sort participants by final rank
* @param {Array} participants - Array of participant objects
* @returns {Array} Sorted array
*/
export function sortParticipantsByRank(participants) {
return [...participants].sort((a, b) => {
// Handle null ranks (participants who didn't finish)
if (a.final_rank === null) return 1;
if (b.final_rank === null) return -1;
return a.final_rank - b.final_rank;
});
}
/**
* Group participants by division (from RK9 data)
* @param {Array} participants - Array of participant objects with rk9Data
* @returns {Object} Object keyed by division name
*/
export function groupParticipantsByDivision(participants) {
return participants.reduce((acc, participant) => {
const division = participant.rk9Data?.division || 'Unknown';
if (!acc[division]) {
acc[division] = [];
}
acc[division].push(participant);
return acc;
}, {});
}
/**
* Calculate participant statistics
* @param {Object} participant - Participant object with match history
* @returns {Object} Statistics {wins, losses, ties, winRate, matchesPlayed}
*/
export function calculateParticipantStats(participant) {
const wins = participant.wins || 0;
const losses = participant.losses || 0;
const ties = participant.ties || 0;
const matchesPlayed = wins + losses + ties;
const winRate = matchesPlayed > 0 ? wins / matchesPlayed : 0;
return {
wins,
losses,
ties,
matchesPlayed,
winRate: Math.round(winRate * 100) / 100 // Round to 2 decimals
};
}
/**
* Find participant by name (case-insensitive, normalized)
* @param {Array} participants - Array of participant objects
* @param {string} searchName - Name to search for
* @returns {Object|null} Found participant or null
*/
export function findParticipantByName(participants, searchName) {
const normalizedSearch = normalizeScreenname(searchName);
return participants.find(
p => normalizeScreenname(p.name) === normalizedSearch
);
}
/**
* Filter participants by check-in status
* @param {Array} participants - Array of participant objects
* @param {boolean} checkedIn - Filter for checked-in (true) or not checked-in (false)
* @returns {Array} Filtered participants
*/
export function filterByCheckInStatus(participants, checkedIn) {
return participants.filter(p => !!p.checked_in_at === checkedIn);
}

View File

@@ -0,0 +1,75 @@
/**
* String Utility Functions
* Common string manipulation and normalization utilities
*/
/**
* Normalize a screenname for reliable matching
* Removes all non-alphanumeric characters and converts to lowercase
* @param {string} name - The screenname to normalize
* @returns {string} Normalized screenname
*/
export function normalizeScreenname(name) {
if (!name) return '';
return name.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
}
/**
* Convert Pokemon ID format to display name
* Example: "BULBASAUR" -> "Bulbasaur"
* Example: "IVYSAUR_NORMAL" -> "Ivysaur"
* @param {string} pokemonId - Pokemon ID from gamemaster
* @returns {string} Display-friendly name
*/
export function pokemonIdToDisplayName(pokemonId) {
if (!pokemonId) return '';
// Remove form suffix (e.g., "_NORMAL", "_ALOLA")
const baseName = pokemonId.split('_')[0];
return baseName.toLowerCase().replace(/\b\w/g, char => char.toUpperCase());
}
/**
* Extract Pokedex number from template ID
* Example: "V0001_POKEMON_BULBASAUR" -> 1
* @param {string} templateId - Template ID from gamemaster
* @returns {number|null} Pokedex number or null if not found
*/
export function extractPokedexNumber(templateId) {
if (!templateId) return null;
const match = templateId.match(/V(\d{4})/);
return match ? parseInt(match[1], 10) : null;
}
/**
* Format Pokemon type for display
* Example: "POKEMON_TYPE_GRASS" -> "Grass"
* @param {string} type - Type from gamemaster
* @returns {string} Display-friendly type name
*/
export function formatPokemonType(type) {
if (!type) return '';
return type
.replace('POKEMON_TYPE_', '')
.toLowerCase()
.replace(/\b\w/g, char => char.toUpperCase());
}
/**
* Debounce function execution
* @param {Function} func - Function to debounce
* @param {number} wait - Milliseconds to wait
* @returns {Function} Debounced function
*/
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}

View File

@@ -15,15 +15,15 @@
* Makes three parallel API calls (pending, in_progress, ended) and combines
* the results while deduplicating by tournament ID.
*
* @param {Object} client - Challonge API client (from createChallongeV2Client)
* @param {Object} [options] - Query options
* @param {string} [options.scopeType] - USER, COMMUNITY, or APPLICATION scope (default: USER)
* @param {string} [options.communityId] - Community ID (if using COMMUNITY scope)
* @param {number} [options.page] - Page number (default: 1)
* @param {number} [options.per_page] - Results per page (default: 25)
* @param {string[]} [options.states] - States to query (default: ['pending', 'in_progress', 'ended'])
* @param {boolean} [options.includeCommunities] - Also query community tournaments (default: false)
* @returns {Promise<any[]>} Combined and deduplicated tournament list
* @param client - Challonge API client (from createChallongeV2Client)
* @param options - Query options
* @param options.scopeType - USER, COMMUNITY, or APPLICATION scope (default: USER)
* @param options.communityId - Community ID (if using COMMUNITY scope)
* @param options.page - Page number (default: 1)
* @param options.per_page - Results per page (default: 25)
* @param options.states - States to query (default: full Challonge state list)
* @param options.includeCommunities - Also query community tournaments (default: false)
* @returns Combined and deduplicated tournament list
*
* @example
* import { queryAllTournaments } from '../utilities/tournament-query.js'
@@ -39,7 +39,17 @@ export async function queryAllTournaments(client, options = {}) {
communityId,
page = 1,
per_page = 25,
states = ['pending', 'in_progress', 'ended'],
states = [
'pending',
'checking_in',
'checked_in',
'accepting_predictions',
'group_stages_underway',
'group_stages_finalized',
'underway',
'awaiting_review',
'complete'
],
includeCommunities = false
} = options;
@@ -53,13 +63,15 @@ export async function queryAllTournaments(client, options = {}) {
// Query all states in parallel
const promises = states.map(state =>
client.tournaments.list({
...baseOptions,
state
}).catch((err) => {
console.error(`Error querying ${state} tournaments:`, err);
return [];
})
client.tournaments
.list({
...baseOptions,
state
})
.catch(err => {
console.error(`Error querying ${state} tournaments:`, err);
return [];
})
);
// Wait for all requests
@@ -69,7 +81,7 @@ export async function queryAllTournaments(client, options = {}) {
const tournamentMap = new Map();
results.forEach(tournamentArray => {
if (Array.isArray(tournamentArray)) {
tournamentArray.forEach((tournament) => {
tournamentArray.forEach(tournament => {
// Handle both v1 and v2.1 response formats
const id = tournament.id || tournament.tournament?.id;
if (id && !tournamentMap.has(id)) {
@@ -88,9 +100,9 @@ export async function queryAllTournaments(client, options = {}) {
* For the USER scope, the Challonge API returns both created and admin tournaments,
* but optionally query across all states for completeness.
*
* @param {Object} client - Challonge API client
* @param {Object} [options] - Query options (same as queryAllTournaments)
* @returns {Promise<any[]>} User's created and admin tournaments
* @param client - Challonge API client
* @param options - Query options (same as queryAllTournaments)
* @returns User's created and admin tournaments
*/
export async function queryUserTournaments(client, options = {}) {
return queryAllTournaments(client, {
@@ -102,12 +114,16 @@ export async function queryUserTournaments(client, options = {}) {
/**
* Query all tournaments in a community (all states)
*
* @param {Object} client - Challonge API client
* @param {string} communityId - Community numeric ID
* @param {Object} [options] - Query options
* @returns {Promise<any[]>} Community tournaments across all states
* @param client - Challonge API client
* @param communityId - Community numeric ID
* @param options - Query options
* @returns Community tournaments across all states
*/
export async function queryCommunityTournaments(client, communityId, options = {}) {
export async function queryCommunityTournaments(
client,
communityId,
options = {}
) {
return queryAllTournaments(client, {
...options,
scopeType: 'COMMUNITY',
@@ -121,10 +137,10 @@ export async function queryCommunityTournaments(client, communityId, options = {
* Useful if you only care about specific states or want to use
* a different set of states than the default.
*
* @param {Object} client - Challonge API client
* @param {string[]} states - States to query (e.g., ['pending', 'in_progress'])
* @param {Object} [options] - Query options
* @returns {Promise<any[]>} Tournaments matching the given states
* @param client - Challonge API client
* @param states - States to query (e.g., ['pending', 'in_progress'])
* @param options - Query options
* @returns Tournaments matching the given states
*/
export async function queryTournamentsByStates(client, states, options = {}) {
return queryAllTournaments(client, {
@@ -136,9 +152,9 @@ export async function queryTournamentsByStates(client, states, options = {}) {
/**
* Query active tournaments only (pending + in_progress)
*
* @param {Object} client - Challonge API client
* @param {Object} [options] - Query options
* @returns {Promise<any[]>} Active tournaments
* @param client - Challonge API client
* @param options - Query options
* @returns Active tournaments
*/
export async function queryActiveTournaments(client, options = {}) {
return queryTournamentsByStates(client, ['pending', 'in_progress'], options);
@@ -147,9 +163,9 @@ export async function queryActiveTournaments(client, options = {}) {
/**
* Query completed tournaments only (ended)
*
* @param {Object} client - Challonge API client
* @param {Object} [options] - Query options
* @returns {Promise<any[]>} Completed tournaments
* @param client - Challonge API client
* @param options - Query options
* @returns Completed tournaments
*/
export async function queryCompletedTournaments(client, options = {}) {
return queryTournamentsByStates(client, ['ended'], options);

View File

@@ -0,0 +1,580 @@
<template>
<div class="api-key-manager">
<div class="container">
<div class="header">
<router-link to="/" class="back-button"> Back Home </router-link>
<h1>API Key Manager</h1>
</div>
<!-- Current Status -->
<div class="section">
<h2>Current Status</h2>
<div v-if="isKeyStored" class="status success">
<div class="status-icon"></div>
<div class="status-content">
<p><strong>API Key Stored</strong></p>
<p class="key-display">{{ maskedKey }}</p>
<button @click="showDeleteConfirm = true" class="btn btn-danger">
Clear Stored Key
</button>
</div>
</div>
<div v-else class="status warning">
<div class="status-icon"></div>
<div class="status-content">
<p><strong>No API Key Stored</strong></p>
<p>Add your Challonge API key below to get started</p>
</div>
</div>
</div>
<!-- Add/Update Key -->
<div class="section">
<div class="section-header">
<h2>{{ isKeyStored ? 'Update' : 'Add' }} Challonge API Key</h2>
<button
@click="showGuide = true"
class="help-btn"
title="How to get a Challonge API key"
>
Need Help?
</button>
</div>
<div class="form-group">
<label for="api-key">Challonge API Key</label>
<div class="input-wrapper">
<input
id="api-key"
v-model="inputKey"
:type="showPassword ? 'text' : 'password'"
placeholder="Enter your Challonge API key"
class="form-input"
/>
<button
@click="showPassword = !showPassword"
class="toggle-password"
:title="showPassword ? 'Hide' : 'Show'"
>
{{ showPassword ? '👁️' : '👁️‍🗨️' }}
</button>
</div>
<p class="help-text">
Get your API key from
<a
href="https://challonge.com/settings/developer"
target="_blank"
rel="noopener"
>
Challonge Developer Settings
</a>
</p>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button
@click="handleSaveKey"
:disabled="!inputKey || saving"
class="btn btn-primary"
>
{{ saving ? 'Saving...' : isKeyStored ? 'Update Key' : 'Save Key' }}
</button>
<div v-if="successMessage" class="success-message">
{{ successMessage }}
</div>
</div>
<!-- Information -->
<div class="section info-section">
<h2> How It Works</h2>
<ul>
<li>
<strong>Secure Storage:</strong> Your API key is stored locally in
your browser using localStorage. It never leaves your device.
</li>
<li>
<strong>Device Specific:</strong> Each device/browser has its own
storage. The key won't sync across devices.
</li>
<li>
<strong>Persistent:</strong> Your key will be available whenever you
use this app, even after closing the browser.
</li>
<li>
<strong>Clear Anytime:</strong> Use the "Clear Stored Key" button to
remove it whenever you want.
</li>
<li>
<strong>Works Everywhere:</strong> Compatible with desktop, mobile,
and tablet browsers.
</li>
</ul>
</div>
<!-- Security Notice -->
<div class="section warning-section">
<h2>🔒 Security Notice</h2>
<p>
⚠️ <strong>localStorage is not encrypted.</strong> Only use this on
trusted devices. If you're on a shared or public computer, clear your
API key when done.
</p>
<p>
For production use, consider using a backend proxy that handles API
keys server-side instead.
</p>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div
v-if="showDeleteConfirm"
class="modal-overlay"
@click="showDeleteConfirm = false"
>
<div class="modal" @click.stop>
<h3>Delete API Key?</h3>
<p>
Are you sure you want to clear the stored API key? You'll need to
enter it again to use the tournament tools.
</p>
<div class="modal-buttons">
<button @click="showDeleteConfirm = false" class="btn btn-secondary">
Cancel
</button>
<button @click="handleDeleteKey" class="btn btn-danger">
Delete
</button>
</div>
</div>
</div>
<!-- Challonge API Key Guide Modal -->
<ChallongeApiKeyGuide v-if="showGuide" @close="showGuide = false" />
</div>
</template>
<script setup>
import { ref } from 'vue';
import ChallongeApiKeyGuide from '../components/ChallongeApiKeyGuide.vue';
import { useChallongeApiKey } from '../composables/useChallongeApiKey.js';
const { saveApiKey, clearApiKey, maskedKey, isKeyStored } =
useChallongeApiKey();
const inputKey = ref('');
const showPassword = ref(false);
const showDeleteConfirm = ref(false);
const saving = ref(false);
const error = ref('');
const successMessage = ref('');
const showGuide = ref(false);
async function handleSaveKey() {
error.value = '';
successMessage.value = '';
// Validate input
if (!inputKey.value.trim()) {
error.value = 'Please enter an API key';
return;
}
if (inputKey.value.length < 10) {
error.value = 'API key appears to be too short';
return;
}
saving.value = true;
try {
const success = saveApiKey(inputKey.value.trim());
if (success) {
successMessage.value = 'API key saved successfully!';
inputKey.value = '';
setTimeout(() => {
successMessage.value = '';
}, 3000);
} else {
error.value = 'Failed to save API key. Please try again.';
}
} finally {
saving.value = false;
}
}
function handleDeleteKey() {
const success = clearApiKey();
if (success) {
showDeleteConfirm.value = false;
inputKey.value = '';
successMessage.value = 'API key cleared successfully';
setTimeout(() => {
successMessage.value = '';
}, 3000);
} else {
error.value = 'Failed to clear API key';
}
}
</script>
<style scoped>
.api-key-manager {
min-height: 100vh;
padding: 2rem 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.back-button {
padding: 0.5rem 1rem;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
transition: all 0.3s ease;
display: inline-block;
}
.back-button:hover {
background: #5568d3;
transform: translateX(-2px);
}
.help-btn {
padding: 0.5rem 1rem;
background: #f59e0b;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: inline-block;
font-size: 0.95rem;
}
.help-btn:hover {
background: #d97706;
transform: translateY(-2px);
}
h1 {
color: #333;
margin: 0;
font-size: 2rem;
}
.section {
margin: 2rem 0;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.section-header h2 {
margin: 0;
}
h2 {
color: #495057;
margin-top: 0;
font-size: 1.3rem;
}
.status {
display: flex;
gap: 1rem;
padding: 1.5rem;
border-radius: 8px;
align-items: flex-start;
}
.status.success {
background: #d1fae5;
border: 2px solid #10b981;
}
.status.warning {
background: #fef3c7;
border: 2px solid #f59e0b;
}
.status-icon {
font-size: 1.5rem;
min-width: 2rem;
}
.status-content p {
margin: 0.5rem 0;
color: #333;
}
.key-display {
font-family: 'Courier New', monospace;
font-weight: 600;
color: #10b981;
font-size: 1.1rem;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
font-weight: 600;
color: #495057;
margin-bottom: 0.5rem;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
}
.form-input {
flex: 1;
padding: 0.75rem;
border: 2px solid #dee2e6;
border-radius: 6px;
font-size: 1rem;
font-family: 'Courier New', monospace;
transition: border-color 0.3s ease;
}
.form-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.toggle-password {
padding: 0.75rem;
background: none;
border: none;
cursor: pointer;
font-size: 1.2rem;
transition: transform 0.2s ease;
}
.toggle-password:hover {
transform: scale(1.1);
}
.help-text {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #666;
}
.help-text a {
color: #667eea;
text-decoration: none;
font-weight: 600;
}
.help-text a:hover {
text-decoration: underline;
}
.btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
}
.btn-secondary {
background: #6b7280;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.error-message {
margin-top: 1rem;
padding: 1rem;
background: #fee;
color: #c33;
border-radius: 6px;
border-left: 4px solid #c33;
}
.success-message {
margin-top: 1rem;
padding: 1rem;
background: #d1fae5;
color: #065f46;
border-radius: 6px;
border-left: 4px solid #10b981;
font-weight: 500;
}
.info-section {
background: #e7f3ff;
border-color: #667eea;
}
.info-section h2 {
color: #667eea;
}
.info-section ul {
margin: 1rem 0;
padding-left: 1.5rem;
}
.info-section li {
margin: 0.75rem 0;
color: #495057;
line-height: 1.6;
}
.warning-section {
background: #fef3c7;
border-color: #f59e0b;
}
.warning-section h2 {
color: #d97706;
}
.warning-section p {
color: #92400e;
line-height: 1.6;
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
padding: 2rem;
border-radius: 12px;
max-width: 400px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.modal h3 {
margin-top: 0;
color: #333;
font-size: 1.5rem;
}
.modal p {
color: #666;
line-height: 1.6;
}
.modal-buttons {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.modal-buttons .btn {
margin: 0;
}
@media (max-width: 640px) {
.container {
padding: 1rem;
}
h1 {
font-size: 1.5rem;
}
.header {
flex-direction: column;
align-items: flex-start;
}
.modal-buttons {
flex-direction: column;
}
.modal-buttons .btn {
width: 100%;
}
}
</style>

View File

@@ -57,7 +57,8 @@
API Key Mode - showing only created tournaments
</div>
<span class="scope-hint">
Shows tournaments you created and tournaments where you're an admin
Shows tournaments you created and tournaments where you're an
admin
</span>
</div>
@@ -474,7 +475,17 @@ async function testListTournaments(resetPagination = true) {
page: currentPage.value,
perPage: 100,
scope: 'USER',
states: ['pending', 'in_progress', 'ended'],
states: [
'pending',
'checking_in',
'checked_in',
'accepting_predictions',
'group_stages_underway',
'group_stages_finalized',
'underway',
'awaiting_review',
'complete'
],
resultsCount: result.length,
isAuthenticated: isAuthenticated.value,
authType: isAuthenticated.value ? 'OAuth' : 'API Key',

View File

@@ -0,0 +1,421 @@
<template>
<div class="gamemaster-manager">
<div class="container">
<div class="header">
<router-link to="/" class="back-button"> Back Home </router-link>
<h1>Gamemaster Manager</h1>
</div>
<p class="description">
Fetch the latest Pokemon GO gamemaster data from PokeMiners and break it
up into separate files for easier processing.
</p>
<!-- Fetch Section -->
<div class="section">
<h2>1. Fetch Latest Gamemaster</h2>
<button
@click="fetchGamemaster"
:disabled="loading"
class="btn btn-primary"
>
{{ loading ? 'Fetching...' : 'Fetch from PokeMiners' }}
</button>
<div v-if="error" class="error">
{{ error }}
</div>
<div v-if="rawGamemaster" class="success">
Fetched {{ rawGamemaster.length.toLocaleString() }} items from
gamemaster
</div>
</div>
<!-- Break Up Section -->
<div v-if="rawGamemaster" class="section">
<h2>2. Break Up Gamemaster</h2>
<button @click="processGamemaster" class="btn btn-primary">
Process & Break Up Data
</button>
<div v-if="processedData" class="stats-grid">
<div class="stat-card">
<h3>Pokemon (Filtered)</h3>
<p class="stat-number">
{{ stats.pokemonCount.toLocaleString() }}
</p>
<p class="stat-detail">{{ stats.pokemonSize }}</p>
<p class="stat-info">Base forms + regional variants</p>
</div>
<div class="stat-card">
<h3>All Forms & Costumes</h3>
<p class="stat-number">
{{ stats.allFormsCount.toLocaleString() }}
</p>
<p class="stat-detail">{{ stats.allFormsSize }}</p>
<p class="stat-info">Every variant, costume, form</p>
</div>
<div class="stat-card">
<h3>Moves</h3>
<p class="stat-number">{{ stats.movesCount.toLocaleString() }}</p>
<p class="stat-detail">{{ stats.movesSize }}</p>
<p class="stat-info">All quick & charged moves</p>
</div>
</div>
</div>
<!-- Download Section -->
<div v-if="processedData" class="section">
<h2>3. Download Files</h2>
<div class="button-group">
<button @click="downloadPokemon" class="btn btn-success">
📥 Download pokemon.json
</button>
<button @click="downloadAllForms" class="btn btn-success">
📥 Download pokemon-allFormsCostumes.json
</button>
<button @click="downloadMoves" class="btn btn-success">
📥 Download pokemon-moves.json
</button>
<button @click="downloadAll" class="btn btn-primary">
📦 Download All Files
</button>
</div>
</div>
<!-- Info Section -->
<div class="section info-section">
<h2>About This Tool</h2>
<p>
This tool fetches the latest Pokemon GO gamemaster data from
<a
href="https://github.com/PokeMiners/game_masters"
target="_blank"
rel="noopener"
>PokeMiners GitHub</a
>
and processes it into three separate files:
</p>
<ul>
<li>
<strong>pokemon.json</strong> - Base Pokemon forms plus regional
variants (Alola, Galarian, Hisuian, Paldea)
</li>
<li>
<strong>pokemon-allFormsCostumes.json</strong> - Complete dataset
including all costumes, event forms, shadows, etc.
</li>
<li>
<strong>pokemon-moves.json</strong> - All quick and charged moves
available in Pokemon GO
</li>
</ul>
<p class="note">
💡 The filtered pokemon.json is ideal for most use cases, while
allFormsCostumes is comprehensive for complete data analysis.
</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import {
fetchLatestGamemaster,
breakUpGamemaster,
downloadJson,
getGamemasterStats
} from '../utilities/gamemaster-utils.js';
const loading = ref(false);
const error = ref(null);
const rawGamemaster = ref(null);
const processedData = ref(null);
const stats = computed(() => {
if (!processedData.value) return null;
return getGamemasterStats(processedData.value);
});
async function fetchGamemaster() {
loading.value = true;
error.value = null;
rawGamemaster.value = null;
processedData.value = null;
try {
const data = await fetchLatestGamemaster();
rawGamemaster.value = data;
} catch (err) {
error.value = `Failed to fetch gamemaster: ${err.message}`;
} finally {
loading.value = false;
}
}
function processGamemaster() {
if (!rawGamemaster.value) return;
try {
processedData.value = breakUpGamemaster(rawGamemaster.value);
} catch (err) {
error.value = `Failed to process gamemaster: ${err.message}`;
}
}
function downloadPokemon() {
downloadJson(processedData.value.pokemon, 'pokemon.json');
}
function downloadAllForms() {
downloadJson(
processedData.value.pokemonAllForms,
'pokemon-allFormsCostumes.json'
);
}
function downloadMoves() {
downloadJson(processedData.value.moves, 'pokemon-moves.json');
}
function downloadAll() {
downloadPokemon();
setTimeout(() => downloadAllForms(), 500);
setTimeout(() => downloadMoves(), 1000);
}
</script>
<style scoped>
.gamemaster-manager {
min-height: 100vh;
padding: 2rem 1rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
}
.header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.back-button {
padding: 0.5rem 1rem;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 6px;
font-weight: 600;
transition: all 0.3s ease;
display: inline-block;
}
.back-button:hover {
background: #5568d3;
transform: translateX(-2px);
}
h1 {
color: #333;
margin: 0;
font-size: 2.5rem;
}
.description {
color: #666;
font-size: 1.1rem;
margin-bottom: 2rem;
}
.section {
margin: 2rem 0;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
h2 {
color: #495057;
margin-bottom: 1rem;
font-size: 1.5rem;
}
.btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
margin-right: 0.5rem;
margin-bottom: 0.5rem;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-success {
background: #10b981;
color: white;
}
.btn-success:hover {
background: #059669;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
.button-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.error {
margin-top: 1rem;
padding: 1rem;
background: #fee;
color: #c33;
border-radius: 6px;
border-left: 4px solid #c33;
}
.success {
margin-top: 1rem;
padding: 1rem;
background: #d1fae5;
color: #065f46;
border-radius: 6px;
border-left: 4px solid #10b981;
font-weight: 500;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
text-align: center;
}
.stat-card h3 {
font-size: 1rem;
color: #666;
margin-bottom: 0.5rem;
}
.stat-number {
font-size: 2.5rem;
font-weight: bold;
color: #667eea;
margin: 0.5rem 0;
}
.stat-detail {
font-size: 1rem;
color: #999;
margin: 0.25rem 0;
}
.stat-info {
font-size: 0.875rem;
color: #666;
margin-top: 0.5rem;
}
.info-section {
background: #e7f3ff;
border-color: #667eea;
}
.info-section h2 {
color: #667eea;
}
.info-section ul {
margin: 1rem 0;
padding-left: 1.5rem;
}
.info-section li {
margin: 0.5rem 0;
color: #495057;
}
.info-section a {
color: #667eea;
text-decoration: none;
font-weight: 600;
}
.info-section a:hover {
text-decoration: underline;
}
.note {
margin-top: 1rem;
padding: 0.75rem;
background: white;
border-radius: 6px;
font-size: 0.95rem;
color: #495057;
}
@media (max-width: 768px) {
.container {
padding: 1rem;
}
h1 {
font-size: 2rem;
}
.stats-grid {
grid-template-columns: 1fr;
}
.button-group {
flex-direction: column;
}
.btn {
width: 100%;
margin-right: 0;
}
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<div class="home-view">
<div class="container">
<!-- Header -->
<div class="header-top">
<ProfessorPokeball size="150px" color="#F44336" :animate="true" />
</div>
<h1>Pokedex Online</h1>
<p class="subtitle">Your Digital Pokédex Companion</p>
<p class="description">
A modern web application for housing different apps that make a
professors life easier. Built with for Pokémon Professors everywhere.
</p>
<div class="tools-section">
<h2>Available Tools</h2>
<div class="tool-cards">
<router-link to="/api-key-manager" class="tool-card settings">
<div class="tool-icon">🔐</div>
<h3>API Key Manager</h3>
<p>Store your Challonge API key locally for easy access</p>
<span v-if="isKeyStored" class="badge">Active</span>
</router-link>
<router-link to="/gamemaster" class="tool-card">
<div class="tool-icon">📦</div>
<h3>Gamemaster Manager</h3>
<p>Fetch and process Pokemon GO gamemaster data from PokeMiners</p>
</router-link>
<router-link to="/challonge-test" class="tool-card">
<div class="tool-icon">🔑</div>
<h3>Challonge API Test</h3>
<p>Test your Challonge API connection and configuration</p>
</router-link>
<div class="tool-card disabled">
<div class="tool-icon">📝</div>
<h3>Printing Tool</h3>
<p>Generate tournament printing materials (Coming Soon)</p>
</div>
<div class="tool-card disabled">
<div class="tool-icon">🏆</div>
<h3>Tournament Manager</h3>
<p>Manage Challonge tournaments and participants (Coming Soon)</p>
</div>
</div>
</div>
<div class="status">
<strong>Status:</strong> In Development<br />
Check back soon for updates!
</div>
</div>
</div>
</template>
<script setup>
import ProfessorPokeball from '../components/shared/ProfessorPokeball.vue';
</script>
<style scoped>
.home-view {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.container {
background: white;
border-radius: 20px;
padding: 60px 40px;
max-width: 900px;
width: 100%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
text-align: center;
}
.header-top {
display: flex;
align-items: center;
justify-content: center;
gap: 2rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
h1 {
color: #333;
margin-bottom: 20px;
font-size: 2.5em;
}
.subtitle {
color: #667eea;
font-size: 1.2em;
margin-bottom: 30px;
}
.description {
color: #666;
line-height: 1.6;
margin-bottom: 40px;
}
.tools-section {
margin: 3rem 0;
}
.tools-section h2 {
color: #333;
margin-bottom: 1.5rem;
font-size: 1.8rem;
}
.tool-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.tool-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 2rem;
border-radius: 12px;
text-decoration: none;
color: white;
transition: all 0.3s ease;
cursor: pointer;
border: none;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
position: relative;
}
.tool-card.settings {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
border: 2px solid #34d399;
}
.tool-card:hover:not(.disabled) {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.tool-card.disabled {
background: linear-gradient(135deg, #999 0%, #666 100%);
opacity: 0.6;
cursor: not-allowed;
}
.tool-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.tool-card h3 {
color: white;
margin-bottom: 0.5rem;
font-size: 1.3rem;
}
.tool-card p {
color: rgba(255, 255, 255, 0.9);
font-size: 0.95rem;
line-height: 1.4;
margin: 0;
}
.badge {
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.2);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
border: 1px solid rgba(255, 255, 255, 0.4);
}
.status {
background: #f0f0f0;
padding: 15px;
border-radius: 10px;
color: #666;
font-size: 0.9em;
}
.status strong {
color: #667eea;
}
@media (max-width: 768px) {
.container {
padding: 40px 20px;
}
h1 {
font-size: 2em;
}
.tool-cards {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,247 @@
<template>
<div class="oauth-callback">
<div class="container">
<div class="callback-card">
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<h2>Authenticating...</h2>
<p>Please wait while we complete your OAuth login</p>
</div>
<div v-else-if="error" class="error-state">
<div class="error-icon"></div>
<h2>Authentication Failed</h2>
<p class="error-message">{{ error }}</p>
<router-link to="/challonge-test" class="btn btn-primary">
Back to Challonge Test
</router-link>
</div>
<div v-else-if="success" class="success-state">
<div class="success-icon"></div>
<h2>Authentication Successful!</h2>
<p>You're now logged in with OAuth</p>
<p class="redirect-info">Redirecting in {{ countdown }} seconds...</p>
<router-link to="/challonge-test" class="btn btn-primary">
Continue to Challonge Test
</router-link>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useChallongeOAuth } from '../composables/useChallongeOAuth.js';
const route = useRoute();
const router = useRouter();
const { exchangeCode } = useChallongeOAuth();
const loading = ref(true);
const error = ref(null);
const success = ref(false);
const countdown = ref(3);
onMounted(async () => {
// Get authorization code and state from URL
const code = route.query.code;
const state = route.query.state;
const errorParam = route.query.error;
const errorDescription = route.query.error_description;
// Handle OAuth errors
if (errorParam) {
loading.value = false;
error.value = errorDescription || `OAuth error: ${errorParam}`;
return;
}
// Validate required parameters
if (!code || !state) {
loading.value = false;
error.value = 'Missing authorization code or state parameter';
return;
}
try {
// Exchange authorization code for tokens
await exchangeCode(code, state);
loading.value = false;
success.value = true;
// Start countdown redirect
const interval = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
clearInterval(interval);
router.push('/challonge-test');
}
}, 1000);
} catch (err) {
loading.value = false;
error.value = err.message || 'Failed to complete OAuth authentication';
console.error('OAuth callback error:', err);
}
});
</script>
<style scoped>
.oauth-callback {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 2rem;
}
.container {
max-width: 500px;
width: 100%;
}
.callback-card {
background: white;
border-radius: 12px;
padding: 3rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
text-align: center;
}
/* Loading State */
.loading-state {
padding: 2rem 0;
}
.spinner {
width: 64px;
height: 64px;
border: 5px solid #f3f3f3;
border-top: 5px solid #667eea;
border-radius: 50%;
margin: 0 auto 2rem;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Success State */
.success-state {
padding: 2rem 0;
}
.success-icon {
width: 80px;
height: 80px;
background: #10b981;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.5rem;
font-size: 3rem;
color: white;
animation: scaleIn 0.5s ease;
}
/* Error State */
.error-state {
padding: 2rem 0;
}
.error-icon {
width: 80px;
height: 80px;
background: #ef4444;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1.5rem;
font-size: 3rem;
color: white;
animation: scaleIn 0.5s ease;
}
@keyframes scaleIn {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
h2 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 1rem;
color: #1f2937;
}
p {
font-size: 1.1rem;
color: #6b7280;
margin-bottom: 1rem;
}
.error-message {
color: #ef4444;
font-weight: 500;
padding: 1rem;
background: #fee2e2;
border-radius: 8px;
margin: 1.5rem 0;
}
.redirect-info {
font-size: 0.95rem;
color: #9ca3af;
margin-top: 1rem;
}
.btn {
display: inline-block;
padding: 0.75rem 2rem;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
margin-top: 1.5rem;
transition: all 0.3s ease;
border: none;
cursor: pointer;
font-size: 1rem;
}
.btn:hover {
background: #5568d3;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-primary {
background: #667eea;
}
.btn-primary:hover {
background: #5568d3;
}
</style>