🎨 Improve code readability by reformatting and updating function definitions and comments
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
41
code/websites/pokedex.online/src/router/index.js
Normal file
41
code/websites/pokedex.online/src/router/index.js
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
65
code/websites/pokedex.online/src/utilities/constants.js
Normal file
65
code/websites/pokedex.online/src/utilities/constants.js
Normal 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);
|
||||
140
code/websites/pokedex.online/src/utilities/csv-utils.js
Normal file
140
code/websites/pokedex.online/src/utilities/csv-utils.js
Normal 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);
|
||||
}
|
||||
51
code/websites/pokedex.online/src/utilities/debug.js
Normal file
51
code/websites/pokedex.online/src/utilities/debug.js
Normal 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);
|
||||
}
|
||||
139
code/websites/pokedex.online/src/utilities/gamemaster-utils.js
Normal file
139
code/websites/pokedex.online/src/utilities/gamemaster-utils.js
Normal 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)
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
129
code/websites/pokedex.online/src/utilities/participant-utils.js
Normal file
129
code/websites/pokedex.online/src/utilities/participant-utils.js
Normal 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);
|
||||
}
|
||||
75
code/websites/pokedex.online/src/utilities/string-utils.js
Normal file
75
code/websites/pokedex.online/src/utilities/string-utils.js
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
580
code/websites/pokedex.online/src/views/ApiKeyManager.vue
Normal file
580
code/websites/pokedex.online/src/views/ApiKeyManager.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
421
code/websites/pokedex.online/src/views/GamemasterManager.vue
Normal file
421
code/websites/pokedex.online/src/views/GamemasterManager.vue
Normal 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>
|
||||
212
code/websites/pokedex.online/src/views/Home.vue
Normal file
212
code/websites/pokedex.online/src/views/Home.vue
Normal 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>
|
||||
247
code/websites/pokedex.online/src/views/OAuthCallback.vue
Normal file
247
code/websites/pokedex.online/src/views/OAuthCallback.vue
Normal 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>
|
||||
Reference in New Issue
Block a user